httpd: Add issue endpoints

Signed-off-by: Sebastian Martinez <me@sebastinez.dev>
This commit is contained in:
Sebastian Martinez 2023-01-19 10:50:08 +01:00 committed by Alexis Sellier
parent 3c202564bf
commit af3ea6a11a
No known key found for this signature in database
6 changed files with 141 additions and 4 deletions

13
Cargo.lock generated
View File

@ -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",

View File

@ -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" }

View File

@ -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),

View File

@ -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>(),

View File

@ -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": {

View File

@ -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();