httpd: Add patch creation endpoint
Signed-off-by: Sebastian Martinez <me@sebastinez.dev>
This commit is contained in:
parent
793c085094
commit
05d2487a47
|
|
@ -34,6 +34,10 @@ pub enum Error {
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
CobIssue(#[from] radicle::cob::issue::Error),
|
CobIssue(#[from] radicle::cob::issue::Error),
|
||||||
|
|
||||||
|
/// Cob patch error.
|
||||||
|
#[error(transparent)]
|
||||||
|
CobPatch(#[from] radicle::cob::patch::Error),
|
||||||
|
|
||||||
/// Cob store error.
|
/// Cob store error.
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
CobStore(#[from] radicle::cob::store::Error),
|
CobStore(#[from] radicle::cob::store::Error),
|
||||||
|
|
|
||||||
|
|
@ -12,13 +12,12 @@ use serde::{Deserialize, Serialize};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use tower_http::set_header::SetResponseHeaderLayer;
|
use tower_http::set_header::SetResponseHeaderLayer;
|
||||||
|
|
||||||
use radicle::cob::issue::{Action, Issues};
|
use radicle::cob::{issue, patch};
|
||||||
use radicle::cob::patch::Patches;
|
|
||||||
use radicle::cob::{thread, ActorId, Tag};
|
use radicle::cob::{thread, ActorId, Tag};
|
||||||
use radicle::identity::Id;
|
use radicle::identity::Id;
|
||||||
use radicle::node::NodeId;
|
use radicle::node::NodeId;
|
||||||
use radicle::storage::git::paths;
|
use radicle::storage::git::paths;
|
||||||
use radicle::storage::{ReadRepository, ReadStorage};
|
use radicle::storage::{ReadRepository, ReadStorage, WriteRepository};
|
||||||
use radicle_surf::{Glob, Oid, Repository};
|
use radicle_surf::{Glob, Oid, Repository};
|
||||||
|
|
||||||
use crate::api::error::Error;
|
use crate::api::error::Error;
|
||||||
|
|
@ -57,7 +56,10 @@ pub fn router(ctx: Context) -> Router {
|
||||||
"/projects/:project/issues/:id",
|
"/projects/:project/issues/:id",
|
||||||
patch(issue_update_handler).get(issue_handler),
|
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))
|
.route("/projects/:project/patches/:id", get(patch_handler))
|
||||||
.with_state(ctx)
|
.with_state(ctx)
|
||||||
}
|
}
|
||||||
|
|
@ -81,9 +83,9 @@ async fn project_root_handler(
|
||||||
let Ok((_, doc)) = repo.identity_doc() else { return None };
|
let Ok((_, doc)) = repo.identity_doc() else { return None };
|
||||||
let Ok(doc) = doc.verified() else { return None };
|
let Ok(doc) = doc.verified() else { return None };
|
||||||
let Ok(payload) = doc.project() 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(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 Ok(patches) = patches.counts() else { return None };
|
||||||
let delegates = doc.delegates;
|
let delegates = doc.delegates;
|
||||||
|
|
||||||
|
|
@ -393,7 +395,7 @@ async fn issues_handler(
|
||||||
let per_page = per_page.unwrap_or(10);
|
let per_page = per_page.unwrap_or(10);
|
||||||
let storage = &ctx.profile.storage;
|
let storage = &ctx.profile.storage;
|
||||||
let repo = storage.repository(project)?;
|
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::<Vec<_>>();
|
let mut issues: Vec<_> = issues.all()?.filter_map(|r| r.ok()).collect::<Vec<_>>();
|
||||||
issues.sort_by(|(_, a, _), (_, b, _)| b.timestamp().cmp(&a.timestamp()));
|
issues.sort_by(|(_, a, _), (_, b, _)| b.timestamp().cmp(&a.timestamp()));
|
||||||
let issues = issues
|
let issues = issues
|
||||||
|
|
@ -430,7 +432,7 @@ async fn issue_create_handler(
|
||||||
.signer()
|
.signer()
|
||||||
.map_err(|_| Error::Auth("Unauthorized"))?;
|
.map_err(|_| Error::Auth("Unauthorized"))?;
|
||||||
let repo = storage.repository(project)?;
|
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
|
let issue = issues
|
||||||
.create(
|
.create(
|
||||||
issue.title,
|
issue.title,
|
||||||
|
|
@ -453,7 +455,7 @@ async fn issue_update_handler(
|
||||||
State(ctx): State<Context>,
|
State(ctx): State<Context>,
|
||||||
AuthBearer(token): AuthBearer,
|
AuthBearer(token): AuthBearer,
|
||||||
Path((project, issue_id)): Path<(Id, Oid)>,
|
Path((project, issue_id)): Path<(Id, Oid)>,
|
||||||
Json(action): Json<Action>,
|
Json(action): Json<issue::Action>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
ctx.sessions
|
ctx.sessions
|
||||||
.write()
|
.write()
|
||||||
|
|
@ -464,24 +466,24 @@ async fn issue_update_handler(
|
||||||
let storage = &ctx.profile.storage;
|
let storage = &ctx.profile.storage;
|
||||||
let signer = ctx.profile.signer().unwrap();
|
let signer = ctx.profile.signer().unwrap();
|
||||||
let repo = storage.repository(project)?;
|
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())?;
|
let mut issue = issues.get_mut(&issue_id.into())?;
|
||||||
|
|
||||||
match action {
|
match action {
|
||||||
Action::Assign { add, remove } => {
|
issue::Action::Assign { add, remove } => {
|
||||||
issue.assign(add, &signer)?;
|
issue.assign(add, &signer)?;
|
||||||
issue.unassign(remove, &signer)?;
|
issue.unassign(remove, &signer)?;
|
||||||
}
|
}
|
||||||
Action::Lifecycle { state } => {
|
issue::Action::Lifecycle { state } => {
|
||||||
issue.lifecycle(state, &signer)?;
|
issue.lifecycle(state, &signer)?;
|
||||||
}
|
}
|
||||||
Action::Tag { add, remove } => {
|
issue::Action::Tag { add, remove } => {
|
||||||
issue.tag(add, remove, &signer)?;
|
issue.tag(add, remove, &signer)?;
|
||||||
}
|
}
|
||||||
Action::Edit { title } => {
|
issue::Action::Edit { title } => {
|
||||||
issue.edit(title, &signer)?;
|
issue.edit(title, &signer)?;
|
||||||
}
|
}
|
||||||
Action::Thread { action } => match action {
|
issue::Action::Thread { action } => match action {
|
||||||
thread::Action::Comment { body, reply_to } => {
|
thread::Action::Comment { body, reply_to } => {
|
||||||
if let Some(reply_to) = reply_to {
|
if let Some(reply_to) = reply_to {
|
||||||
issue.comment(body, reply_to, &signer)?;
|
issue.comment(body, reply_to, &signer)?;
|
||||||
|
|
@ -512,13 +514,62 @@ async fn issue_handler(
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let storage = &ctx.profile.storage;
|
let storage = &ctx.profile.storage;
|
||||||
let repo = storage.repository(project)?;
|
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())?
|
.get(&issue_id.into())?
|
||||||
.ok_or(Error::NotFound)?;
|
.ok_or(Error::NotFound)?;
|
||||||
|
|
||||||
Ok::<_, Error>(Json(api::json::issue(issue_id.into(), issue)))
|
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<Tag>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new patch.
|
||||||
|
/// `POST /projects/:project/patches`
|
||||||
|
async fn patch_create_handler(
|
||||||
|
State(ctx): State<Context>,
|
||||||
|
AuthBearer(token): AuthBearer,
|
||||||
|
Path(project): Path<Id>,
|
||||||
|
Json(patch): Json<PatchCreate>,
|
||||||
|
) -> 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 project patches list.
|
||||||
/// `GET /projects/:project/patches`
|
/// `GET /projects/:project/patches`
|
||||||
async fn patches_handler(
|
async fn patches_handler(
|
||||||
|
|
@ -531,7 +582,7 @@ async fn patches_handler(
|
||||||
let per_page = per_page.unwrap_or(10);
|
let per_page = per_page.unwrap_or(10);
|
||||||
let storage = &ctx.profile.storage;
|
let storage = &ctx.profile.storage;
|
||||||
let repo = storage.repository(project)?;
|
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::<Vec<_>>();
|
let mut patches = patches.all()?.filter_map(|r| r.ok()).collect::<Vec<_>>();
|
||||||
patches.sort_by(|(_, a, _), (_, b, _)| b.timestamp().cmp(&a.timestamp()));
|
patches.sort_by(|(_, a, _), (_, b, _)| b.timestamp().cmp(&a.timestamp()));
|
||||||
let patches = patches
|
let patches = patches
|
||||||
|
|
@ -552,7 +603,7 @@ async fn patch_handler(
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let storage = &ctx.profile.storage;
|
let storage = &ctx.profile.storage;
|
||||||
let repo = storage.repository(project)?;
|
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())?
|
.get(&patch_id.into())?
|
||||||
.ok_or(Error::NotFound)?;
|
.ok_or(Error::NotFound)?;
|
||||||
|
|
||||||
|
|
@ -566,7 +617,7 @@ mod routes {
|
||||||
use pretty_assertions::assert_eq;
|
use pretty_assertions::assert_eq;
|
||||||
use serde_json::json;
|
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";
|
const CREATED_ISSUE_ID: &str = "745052a1603000b9566445753d7e2fee1ff5041f";
|
||||||
|
|
||||||
|
|
@ -1081,7 +1132,7 @@ mod routes {
|
||||||
&app,
|
&app,
|
||||||
"/projects/rad:z4FucBZHZMCsxTyQE1dfE2YR59Qbp/issues",
|
"/projects/rad:z4FucBZHZMCsxTyQE1dfE2YR59Qbp/issues",
|
||||||
Some(Body::from(body)),
|
Some(Body::from(body)),
|
||||||
Some("u9MGAkkfkMOv0uDDB2WeUHBT7HbsO2Dy".to_string()),
|
Some(SESSION_ID.to_string()),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
|
@ -1144,7 +1195,7 @@ mod routes {
|
||||||
&app,
|
&app,
|
||||||
format!("/projects/rad:z4FucBZHZMCsxTyQE1dfE2YR59Qbp/issues/{ISSUE_ID}"),
|
format!("/projects/rad:z4FucBZHZMCsxTyQE1dfE2YR59Qbp/issues/{ISSUE_ID}"),
|
||||||
Some(Body::from(body)),
|
Some(Body::from(body)),
|
||||||
Some("u9MGAkkfkMOv0uDDB2WeUHBT7HbsO2Dy".to_string()),
|
Some(SESSION_ID.to_string()),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
|
@ -1214,7 +1265,7 @@ mod routes {
|
||||||
&app,
|
&app,
|
||||||
format!("/projects/rad:z4FucBZHZMCsxTyQE1dfE2YR59Qbp/issues/{ISSUE_ID}"),
|
format!("/projects/rad:z4FucBZHZMCsxTyQE1dfE2YR59Qbp/issues/{ISSUE_ID}"),
|
||||||
Some(Body::from(body)),
|
Some(Body::from(body)),
|
||||||
Some("u9MGAkkfkMOv0uDDB2WeUHBT7HbsO2Dy".to_string()),
|
Some(SESSION_ID.to_string()),
|
||||||
)
|
)
|
||||||
.await;
|
.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": [],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ pub const HEAD: &str = "1e978d19f251cd9821d9d9a76d1bd436bf0690d5";
|
||||||
pub const HEAD_1: &str = "f604ce9fd5b7cc77b7609beda45ea8760bee78f7";
|
pub const HEAD_1: &str = "f604ce9fd5b7cc77b7609beda45ea8760bee78f7";
|
||||||
pub const PATCH_ID: &str = "afb3063f8f0343fa31d2a0d55bac2a6f4a77125e";
|
pub const PATCH_ID: &str = "afb3063f8f0343fa31d2a0d55bac2a6f4a77125e";
|
||||||
pub const ISSUE_ID: &str = "331569cd5e4dcc55104363ebce92c78b0e5d67d4";
|
pub const ISSUE_ID: &str = "331569cd5e4dcc55104363ebce92c78b0e5d67d4";
|
||||||
|
pub const SESSION_ID: &str = "u9MGAkkfkMOv0uDDB2WeUHBT7HbsO2Dy";
|
||||||
|
|
||||||
const PASSWORD: &str = "radicle";
|
const PASSWORD: &str = "radicle";
|
||||||
|
|
||||||
|
|
@ -144,7 +145,7 @@ pub async fn create_session(ctx: Context) {
|
||||||
let issued_at = OffsetDateTime::now_utc();
|
let issued_at = OffsetDateTime::now_utc();
|
||||||
let mut sessions = ctx.sessions().write().await;
|
let mut sessions = ctx.sessions().write().await;
|
||||||
sessions.insert(
|
sessions.insert(
|
||||||
String::from("u9MGAkkfkMOv0uDDB2WeUHBT7HbsO2Dy"),
|
String::from(SESSION_ID),
|
||||||
auth::Session {
|
auth::Session {
|
||||||
status: auth::AuthState::Authorized,
|
status: auth::AuthState::Authorized,
|
||||||
public_key: ctx.profile().public_key,
|
public_key: ctx.profile().public_key,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue