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",
|
||||
]
|
||||
|
||||
[[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",
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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::<Vec<_>>(),
|
||||
"title": issue.title(),
|
||||
"state": issue.state(),
|
||||
"discussion": issue.comments().collect::<Comments>(),
|
||||
|
|
|
|||
|
|
@ -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<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 /projects/:project/issues/:id`
|
||||
async fn issue_handler(
|
||||
|
|
@ -909,6 +1003,7 @@ mod routes {
|
|||
"state": {
|
||||
"status": "open"
|
||||
},
|
||||
"assignees": [],
|
||||
"discussion": [
|
||||
{
|
||||
"author": {
|
||||
|
|
|
|||
|
|
@ -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<G: Signer>(&mut self, title: impl ToString, signer: &G) -> Result<OpId, Error> {
|
||||
self.transaction("Edit", signer, |tx| tx.edit(title))
|
||||
}
|
||||
|
||||
/// Lifecycle an issue.
|
||||
pub fn lifecycle<G: Signer>(&mut self, state: State, signer: &G) -> Result<OpId, Error> {
|
||||
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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue