httpd: Add issue endpoints
Signed-off-by: Sebastian Martinez <me@sebastinez.dev>
This commit is contained in:
parent
3c202564bf
commit
af3ea6a11a
|
|
@ -153,6 +153,18 @@ dependencies = [
|
||||||
"tower-service",
|
"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]]
|
[[package]]
|
||||||
name = "axum-core"
|
name = "axum-core"
|
||||||
version = "0.3.2"
|
version = "0.3.2"
|
||||||
|
|
@ -1938,6 +1950,7 @@ version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
|
"axum-auth",
|
||||||
"axum-server",
|
"axum-server",
|
||||||
"chrono",
|
"chrono",
|
||||||
"fastrand",
|
"fastrand",
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ logfmt = [
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = { version = "1" }
|
anyhow = { version = "1" }
|
||||||
axum = { version = "0.6.2", default-features = false, features = ["headers", "json", "query", "tokio"] }
|
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 }
|
axum-server = { version = "0.4.4", default-features = false }
|
||||||
chrono = { version = "0.4.22" }
|
chrono = { version = "0.4.22" }
|
||||||
fastrand = { version = "1.7.0" }
|
fastrand = { version = "1.7.0" }
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,10 @@ pub enum Error {
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
Storage(#[from] radicle::storage::Error),
|
Storage(#[from] radicle::storage::Error),
|
||||||
|
|
||||||
|
/// Cob issue error.
|
||||||
|
#[error(transparent)]
|
||||||
|
CobIssue(#[from] radicle::cob::issue::Error),
|
||||||
|
|
||||||
/// Cob store error.
|
/// Cob store error.
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
CobStore(#[from] radicle::cob::store::Error),
|
CobStore(#[from] radicle::cob::store::Error),
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,7 @@ pub(crate) fn issue(id: IssueId, issue: Issue) -> Value {
|
||||||
json!({
|
json!({
|
||||||
"id": id.to_string(),
|
"id": id.to_string(),
|
||||||
"author": issue.author(),
|
"author": issue.author(),
|
||||||
|
"assignees": issue.assigned().collect::<Vec<_>>(),
|
||||||
"title": issue.title(),
|
"title": issue.title(),
|
||||||
"state": issue.state(),
|
"state": issue.state(),
|
||||||
"discussion": issue.comments().collect::<Comments>(),
|
"discussion": issue.comments().collect::<Comments>(),
|
||||||
|
|
|
||||||
|
|
@ -4,15 +4,17 @@ use axum::extract::State;
|
||||||
use axum::handler::Handler;
|
use axum::handler::Handler;
|
||||||
use axum::http::{header, HeaderValue};
|
use axum::http::{header, HeaderValue};
|
||||||
use axum::response::IntoResponse;
|
use axum::response::IntoResponse;
|
||||||
use axum::routing::get;
|
use axum::routing::{get, patch, post};
|
||||||
use axum::{Json, Router};
|
use axum::{Json, Router};
|
||||||
|
use axum_auth::AuthBearer;
|
||||||
use hyper::StatusCode;
|
use hyper::StatusCode;
|
||||||
use serde::{Deserialize, Serialize};
|
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::Issues;
|
use radicle::cob::issue::{Action, Issues};
|
||||||
use radicle::cob::patch::Patches;
|
use radicle::cob::patch::Patches;
|
||||||
|
use radicle::cob::{thread, Tag};
|
||||||
use radicle::identity::Id;
|
use radicle::identity::Id;
|
||||||
use radicle::node::NodeId;
|
use radicle::node::NodeId;
|
||||||
use radicle::storage::{git::paths, ReadRepository, WriteStorage};
|
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/remotes/:peer", get(remote_handler))
|
||||||
.route("/projects/:project/blob/:sha/*path", get(blob_handler))
|
.route("/projects/:project/blob/:sha/*path", get(blob_handler))
|
||||||
.route("/projects/:project/readme/:sha", get(readme_handler))
|
.route("/projects/:project/readme/:sha", get(readme_handler))
|
||||||
.route("/projects/:project/issues", get(issues_handler))
|
.route(
|
||||||
.route("/projects/:project/issues/:id", get(issue_handler))
|
"/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", 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)
|
||||||
|
|
@ -391,6 +399,92 @@ async fn issues_handler(
|
||||||
Ok::<_, Error>(Json(issues))
|
Ok::<_, Error>(Json(issues))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
pub struct IssueCreate {
|
||||||
|
pub title: String,
|
||||||
|
pub description: String,
|
||||||
|
pub tags: Vec<Tag>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new issue.
|
||||||
|
/// `POST /projects/:project/issues`
|
||||||
|
async fn issue_create_handler(
|
||||||
|
State(ctx): State<Context>,
|
||||||
|
AuthBearer(token): AuthBearer,
|
||||||
|
Path(project): Path<Id>,
|
||||||
|
Json(issue): Json<IssueCreate>,
|
||||||
|
) -> 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<Context>,
|
||||||
|
AuthBearer(token): AuthBearer,
|
||||||
|
Path((project, issue_id)): Path<(Id, Oid)>,
|
||||||
|
Json(action): Json<Action>,
|
||||||
|
) -> 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 project issue.
|
||||||
/// `GET /projects/:project/issues/:id`
|
/// `GET /projects/:project/issues/:id`
|
||||||
async fn issue_handler(
|
async fn issue_handler(
|
||||||
|
|
@ -909,6 +1003,7 @@ mod routes {
|
||||||
"state": {
|
"state": {
|
||||||
"status": "open"
|
"status": "open"
|
||||||
},
|
},
|
||||||
|
"assignees": [],
|
||||||
"discussion": [
|
"discussion": [
|
||||||
{
|
{
|
||||||
"author": {
|
"author": {
|
||||||
|
|
|
||||||
|
|
@ -274,6 +274,11 @@ impl<'a, 'g> IssueMut<'a, 'g> {
|
||||||
self.transaction("Assign", signer, |tx| tx.assign(assignees, vec![]))
|
self.transaction("Assign", signer, |tx| tx.assign(assignees, vec![]))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set the issue title.
|
||||||
|
pub fn edit<G: Signer>(&mut self, title: impl ToString, signer: &G) -> Result<OpId, Error> {
|
||||||
|
self.transaction("Edit", signer, |tx| tx.edit(title))
|
||||||
|
}
|
||||||
|
|
||||||
/// Lifecycle an issue.
|
/// Lifecycle an issue.
|
||||||
pub fn lifecycle<G: Signer>(&mut self, state: State, signer: &G) -> Result<OpId, Error> {
|
pub fn lifecycle<G: Signer>(&mut self, state: State, signer: &G) -> Result<OpId, Error> {
|
||||||
self.transaction("Lifecycle", signer, |tx| tx.lifecycle(state))
|
self.transaction("Lifecycle", signer, |tx| tx.lifecycle(state))
|
||||||
|
|
@ -598,6 +603,24 @@ mod test {
|
||||||
assert!(assignees.contains(&assignee_two));
|
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]
|
#[test]
|
||||||
fn test_issue_react() {
|
fn test_issue_react() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue