use std::sync::Arc;
use axum::{
extract::{Path, Query, State},
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use crate::{AppError, AppState, TicketSource};
use crate::models::*;
pub(crate) fn extract_section<'a>(body: &'a str, heading: &str) -> &'a str {
let marker = format!("### {heading}");
let Some(start) = body.find(&marker) else {
return "";
};
let after = &body[start + marker.len()..];
match after.find("\n###") {
Some(end) => &after[..end],
None => after,
}
}
pub async fn load_tickets(state: &AppState) -> Result<Vec<apm_core::ticket::Ticket>, AppError> {
match &state.source {
TicketSource::Git(root, tickets_dir) => {
let root = root.clone();
let tickets_dir = tickets_dir.clone();
Ok(crate::util::blocking(move || {
apm_core::ticket::load_all_from_git(&root, &tickets_dir)
}).await?)
}
TicketSource::InMemory(tickets) => Ok(tickets.clone()),
}
}
pub async fn list_tickets(
State(state): State<Arc<AppState>>,
Query(params): Query<ListTicketsQuery>,
) -> Result<Json<TicketsEnvelope>, AppError> {
let mut tickets = load_tickets(&state).await?;
let fallback_supervisor_states = || vec![
"new".to_string(), "question".to_string(), "specd".to_string(),
"blocked".to_string(), "implemented".to_string(), "merge_failed".to_string(),
];
let (resolved_ids, terminal_ids, supervisor_states): (Vec<String>, Vec<String>, Vec<String>) = match state.git_root() {
Some(root) => match apm_core::config::Config::load(root) {
Ok(cfg) => {
let resolved = cfg.workflow.states.iter()
.filter(|s| matches!(s.satisfies_deps, apm_core::config::SatisfiesDeps::Bool(true)) || s.terminal)
.map(|s| s.id.clone())
.collect();
let mut supervisor = vec!["new".to_string()];
supervisor.extend(
cfg.workflow.states.iter()
.filter(|s| !s.terminal && s.id != "new" && s.actionable.iter().any(|a| a == "supervisor"))
.map(|s| s.id.clone())
);
let terminal = cfg.workflow.states.into_iter()
.filter(|s| s.terminal)
.map(|s| s.id)
.collect();
(resolved, terminal, supervisor)
}
Err(_) => (vec![], vec!["closed".to_string()], fallback_supervisor_states()),
},
None => (vec![], vec!["closed".to_string()], fallback_supervisor_states()),
};
if !params.include_closed.unwrap_or(false) {
let terminal_set: std::collections::HashSet<&str> =
terminal_ids.iter().map(|s| s.as_str()).collect();
tickets.retain(|t| !terminal_set.contains(t.frontmatter.state.as_str()));
}
if let Some(ref author) = params.author {
tickets.retain(|t| {
let a = t.frontmatter.author.as_deref().unwrap_or("unassigned");
a == author.as_str()
});
}
if let Some(ref owner) = params.owner {
if owner == "unassigned" {
tickets.retain(|t| t.frontmatter.owner.is_none());
} else {
tickets.retain(|t| t.frontmatter.owner.as_deref() == Some(owner.as_str()));
}
}
let resolved: std::collections::HashSet<&str> =
resolved_ids.iter().map(|s| s.as_str()).collect();
let state_map: std::collections::HashMap<String, String> = tickets
.iter()
.map(|t| (t.frontmatter.id.clone(), t.frontmatter.state.clone()))
.collect();
let tickets: Vec<TicketResponse> = tickets
.into_iter()
.map(|t| {
let has_open_questions = !extract_section(&t.body, "Open questions").trim().is_empty();
let has_pending_amendments = extract_section(&t.body, "Amendment requests").contains("- [ ]");
let blocking_deps = t.frontmatter.depends_on
.as_deref()
.unwrap_or(&[])
.iter()
.filter_map(|dep_id| {
state_map.get(dep_id.as_str()).and_then(|s| {
if resolved.contains(s.as_str()) { None }
else { Some(BlockingDep { id: dep_id.clone(), state: s.clone() }) }
})
})
.collect();
let mut fm = t.frontmatter;
if fm.author.is_none() {
fm.author = Some("unassigned".to_string());
}
let owner = fm.owner.take();
TicketResponse {
frontmatter: fm,
body: t.body,
has_open_questions,
has_pending_amendments,
blocking_deps,
owner,
}
})
.collect();
Ok(Json(TicketsEnvelope { tickets, supervisor_states }))
}
pub async fn get_ticket(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Result<Response, AppError> {
let tickets = load_tickets(&state).await?;
match apm_core::ticket::resolve_id_in_slice(&tickets, &id) {
Err(e) => {
let msg = e.to_string();
if msg.contains("no ticket matches") {
Ok((StatusCode::NOT_FOUND, msg).into_response())
} else if msg.contains("invalid ticket ID") {
Ok((StatusCode::BAD_REQUEST, msg).into_response())
} else {
Err(AppError(e))
}
}
Ok(full_id) => {
let (blocking_deps, valid_transitions) = match state.git_root() {
None => (vec![], vec![]),
Some(root) => {
let root = root.clone();
let ticket_ref = tickets.iter().find(|t| t.frontmatter.id == full_id).unwrap();
let deps = match apm_core::config::Config::load(&root) {
Ok(config) => apm_core::compute_blocking_deps(ticket_ref, &tickets, &config),
Err(_) => vec![],
};
let state_str = ticket_ref.frontmatter.state.clone();
let transitions = tokio::task::spawn_blocking(move || {
let config = match apm_core::config::Config::load(&root) {
Ok(c) => c,
Err(_) => return vec![],
};
apm_core::compute_valid_transitions(&state_str, &config)
}).await?;
(deps, transitions)
}
};
let mut ticket = tickets.into_iter().find(|t| t.frontmatter.id == full_id).unwrap();
let raw = ticket.serialize().unwrap_or_default();
let (spec, _) = apm_core::review::split_body(&ticket.body);
if ticket.frontmatter.author.is_none() {
ticket.frontmatter.author = Some("unassigned".to_string());
}
let owner = ticket.frontmatter.owner.take();
Ok(Json(TicketDetailResponse {
frontmatter: ticket.frontmatter,
body: ticket.body,
raw,
spec,
valid_transitions,
blocking_deps,
owner,
})
.into_response())
}
}
}
pub async fn transition_ticket(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
Json(req): Json<TransitionRequest>,
) -> Result<Response, AppError> {
let root = match state.git_root() {
Some(r) => r.clone(),
None => {
return Ok((StatusCode::NOT_IMPLEMENTED, "no git root").into_response());
}
};
let to_state = req.to.clone();
let id_clone = id.clone();
let root_clone = root.clone();
let result = tokio::task::spawn_blocking(move || {
apm_core::state::transition(&root_clone, &id_clone, to_state, false, false)
})
.await?;
match result {
Err(e) => {
let msg = e.to_string();
if msg.contains("no ticket matches") {
Ok((
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": msg})),
)
.into_response())
} else {
Ok((
StatusCode::UNPROCESSABLE_ENTITY,
Json(serde_json::json!({"error": msg})),
)
.into_response())
}
}
Ok(_output) => {
let tickets = load_tickets(&state).await?;
match apm_core::ticket::resolve_id_in_slice(&tickets, &id) {
Err(e) => Err(AppError(e)),
Ok(full_id) => {
let blocking_deps = match apm_core::config::Config::load(&root) {
Ok(config) => apm_core::compute_blocking_deps(
tickets.iter().find(|t| t.frontmatter.id == full_id).unwrap(),
&tickets,
&config,
),
Err(_) => vec![],
};
let ticket =
tickets.into_iter().find(|t| t.frontmatter.id == full_id).unwrap();
let state_str = ticket.frontmatter.state.clone();
let root2 = root.clone();
let valid_transitions = tokio::task::spawn_blocking(move || {
let config = match apm_core::config::Config::load(&root2) {
Ok(c) => c,
Err(_) => return vec![],
};
apm_core::compute_valid_transitions(&state_str, &config)
})
.await?;
let raw = ticket.serialize().unwrap_or_default();
let (spec, _) = apm_core::review::split_body(&ticket.body);
let owner = ticket.frontmatter.owner.clone();
let mut fm = ticket.frontmatter;
fm.owner = None;
Ok(Json(TicketDetailResponse {
frontmatter: fm,
body: ticket.body,
raw,
spec,
valid_transitions,
blocking_deps,
owner,
})
.into_response())
}
}
}
}
}
pub async fn put_body(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
Json(req): Json<PutBodyRequest>,
) -> Result<Response, AppError> {
if req.spec.contains("+++") {
return Ok((
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "spec must not contain front matter delimiters (+++)"})),
)
.into_response());
}
if req.spec.contains("## History") {
return Ok((
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "spec must not contain a ## History section"})),
)
.into_response());
}
let root = match state.git_root() {
Some(r) => r.clone(),
None => return Ok((StatusCode::NOT_IMPLEMENTED, "no git root").into_response()),
};
let tickets = load_tickets(&state).await?;
let full_id = match apm_core::ticket::resolve_id_in_slice(&tickets, &id) {
Err(e) => {
let msg = e.to_string();
if msg.contains("no ticket matches") {
return Ok((StatusCode::NOT_FOUND, msg).into_response());
} else if msg.contains("invalid ticket ID") {
return Ok((StatusCode::BAD_REQUEST, msg).into_response());
} else {
return Err(AppError(e));
}
}
Ok(id) => id,
};
let ticket = tickets.into_iter().find(|t| t.frontmatter.id == full_id).unwrap();
let branch = match ticket.frontmatter.branch.clone() {
Some(b) => b,
None => {
return Ok((
StatusCode::UNPROCESSABLE_ENTITY,
Json(serde_json::json!({"error": "ticket has no branch"})),
)
.into_response())
}
};
let rel_path = match ticket.path.strip_prefix(&root) {
Ok(p) => p.to_string_lossy().to_string(),
Err(_) => {
return Err(AppError(anyhow::anyhow!("cannot compute relative path for ticket")))
}
};
let ticket_path = ticket.path.clone();
let new_spec = req.spec.clone();
let root_clone = root.clone();
let branch_clone = branch.clone();
let rel_path_clone = rel_path.clone();
let new_content = crate::util::blocking(move || {
let current_content = apm_core::git::read_from_branch(&root_clone, &branch_clone, &rel_path_clone)?;
let current_ticket = apm_core::ticket::Ticket::parse(&ticket_path, ¤t_content)?;
let (_, current_history) = apm_core::review::split_body(¤t_ticket.body);
let new_body = format!("{}{}", new_spec.trim_end(), current_history);
let updated = apm_core::ticket::Ticket {
frontmatter: current_ticket.frontmatter,
body: new_body,
path: current_ticket.path,
};
updated.serialize()
}).await?;
crate::util::blocking(move || {
apm_core::git::commit_to_branch(&root, &branch, &rel_path, &new_content, "ui: edit ticket body")
}).await?;
Ok(Json(serde_json::json!({"ok": true})).into_response())
}
pub async fn patch_ticket(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
Json(req): Json<PatchTicketRequest>,
) -> Result<Response, AppError> {
let root = match state.git_root() {
Some(r) => r.clone(),
None => return Ok((StatusCode::NOT_IMPLEMENTED, "no git root").into_response()),
};
let tickets = load_tickets(&state).await?;
let full_id = match apm_core::ticket::resolve_id_in_slice(&tickets, &id) {
Err(e) => {
let msg = e.to_string();
if msg.contains("no ticket matches") {
return Ok((StatusCode::NOT_FOUND, msg).into_response());
} else if msg.contains("invalid ticket ID") {
return Ok((StatusCode::BAD_REQUEST, msg).into_response());
} else {
return Err(AppError(e));
}
}
Ok(id) => id,
};
let blocking_deps = match apm_core::config::Config::load(&root) {
Ok(config) => apm_core::compute_blocking_deps(
tickets.iter().find(|t| t.frontmatter.id == full_id).unwrap(),
&tickets,
&config,
),
Err(_) => vec![],
};
let ticket = tickets.into_iter().find(|t| t.frontmatter.id == full_id).unwrap();
let branch = match ticket.frontmatter.branch.clone() {
Some(b) => b,
None => {
return Ok((
StatusCode::UNPROCESSABLE_ENTITY,
Json(serde_json::json!({"error": "ticket has no branch"})),
)
.into_response())
}
};
let rel_path = match ticket.path.strip_prefix(&root) {
Ok(p) => p.to_string_lossy().to_string(),
Err(_) => {
return Err(AppError(anyhow::anyhow!("cannot compute relative path for ticket")))
}
};
let mut fm = ticket.frontmatter;
let body = ticket.body;
if let Some(v) = req.effort {
apm_core::ticket::set_field(&mut fm, "effort", &v.to_string())?;
}
if let Some(v) = req.risk {
apm_core::ticket::set_field(&mut fm, "risk", &v.to_string())?;
}
if let Some(v) = req.priority {
apm_core::ticket::set_field(&mut fm, "priority", &v.to_string())?;
}
if let Some(v) = req.owner {
let val = if v.is_empty() { "-".to_string() } else { v };
apm_core::ticket::set_field(&mut fm, "owner", &val)?;
}
let updated = apm_core::ticket::Ticket {
frontmatter: fm,
body,
path: ticket.path,
};
let content = updated
.serialize()
.map_err(|e| AppError(anyhow::anyhow!("cannot serialize ticket: {e}")))?;
let root_clone = root.clone();
crate::util::blocking(move || {
apm_core::git::commit_to_branch(
&root_clone,
&branch,
&rel_path,
&content,
"ui: update ticket fields",
)
}).await?;
let state_str = updated.frontmatter.state.clone();
let valid_transitions = tokio::task::spawn_blocking(move || {
let config = match apm_core::config::Config::load(&root) {
Ok(c) => c,
Err(_) => return vec![],
};
apm_core::compute_valid_transitions(&state_str, &config)
})
.await?;
let raw = updated.serialize().unwrap_or_default();
let (spec, _) = apm_core::review::split_body(&updated.body);
let owner = updated.frontmatter.owner.clone();
let mut fm = updated.frontmatter;
fm.owner = None;
Ok(Json(TicketDetailResponse {
frontmatter: fm,
body: updated.body,
raw,
spec,
valid_transitions,
blocking_deps,
owner,
})
.into_response())
}
pub async fn batch_transition(
State(state): State<Arc<AppState>>,
Json(req): Json<BatchTransitionRequest>,
) -> Result<Response, AppError> {
let root = match state.git_root() {
Some(r) => r.clone(),
None => return Ok((StatusCode::NOT_IMPLEMENTED, "no git root").into_response()),
};
let mut succeeded = Vec::new();
let mut failed = Vec::new();
for id in req.ids {
let root_clone = root.clone();
let id_clone = id.clone();
let to_clone = req.to.clone();
let result = tokio::task::spawn_blocking(move || {
apm_core::state::transition(&root_clone, &id_clone, to_clone, false, false)
})
.await?;
match result {
Ok(_) => succeeded.push(id),
Err(e) => failed.push(BatchFailure { id, error: e.to_string() }),
}
}
Ok(Json(BatchResult { succeeded, failed }).into_response())
}
pub async fn batch_priority(
State(state): State<Arc<AppState>>,
Json(req): Json<BatchPriorityRequest>,
) -> Result<Response, AppError> {
let root = match state.git_root() {
Some(r) => r.clone(),
None => return Ok((StatusCode::NOT_IMPLEMENTED, "no git root").into_response()),
};
let tickets = load_tickets(&state).await?;
let mut succeeded = Vec::new();
let mut failed = Vec::new();
for id in req.ids {
let full_id = match apm_core::ticket::resolve_id_in_slice(&tickets, &id) {
Ok(fid) => fid,
Err(e) => {
failed.push(BatchFailure { id, error: e.to_string() });
continue;
}
};
let ticket = match tickets.iter().find(|t| t.frontmatter.id == full_id) {
Some(t) => t.clone(),
None => {
failed.push(BatchFailure { id, error: "not found".to_string() });
continue;
}
};
let branch = match ticket.frontmatter.branch.clone() {
Some(b) => b,
None => {
failed.push(BatchFailure { id, error: "ticket has no branch".to_string() });
continue;
}
};
let rel_path = match ticket.path.strip_prefix(&root) {
Ok(p) => p.to_string_lossy().to_string(),
Err(_) => {
failed.push(BatchFailure { id, error: "cannot compute relative path".to_string() });
continue;
}
};
let mut fm = ticket.frontmatter.clone();
let body = ticket.body.clone();
if let Err(e) = apm_core::ticket::set_field(&mut fm, "priority", &req.priority.to_string()) {
failed.push(BatchFailure { id, error: e.to_string() });
continue;
}
let updated = apm_core::ticket::Ticket { frontmatter: fm, body, path: ticket.path.clone() };
let content = match updated.serialize() {
Ok(c) => c,
Err(e) => {
failed.push(BatchFailure { id, error: e.to_string() });
continue;
}
};
let root_clone = root.clone();
let result = tokio::task::spawn_blocking(move || {
apm_core::git::commit_to_branch(&root_clone, &branch, &rel_path, &content, "ui: batch update priority")
})
.await?;
match result {
Ok(_) => succeeded.push(full_id),
Err(e) => failed.push(BatchFailure { id: updated.frontmatter.id, error: e.to_string() }),
}
}
Ok(Json(BatchResult { succeeded, failed }).into_response())
}
pub async fn create_ticket(
State(state): State<Arc<AppState>>,
Json(req): Json<CreateTicketRequest>,
) -> Result<Response, AppError> {
let title = match req.title {
Some(t) if !t.trim().is_empty() => t,
_ => {
return Ok((
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "title is required"})),
)
.into_response());
}
};
let root = match state.git_root() {
Some(r) => r.clone(),
None => return Ok((StatusCode::NOT_IMPLEMENTED, "no git root").into_response()),
};
let section_sets: Vec<(String, String)> = req.sections
.unwrap_or_default()
.into_iter()
.filter(|(_, v)| !v.trim().is_empty())
.collect();
let depends_on = req.depends_on;
let (epic, target_branch) = match req.epic {
None => (None, None),
Some(ref short_id) => {
match apm_core::epic::find_epic_branch(&root, short_id) {
Some(branch) => (Some(short_id.clone()), Some(branch)),
None => {
return Ok((
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": format!("no epic branch found for id {short_id}")})),
)
.into_response());
}
}
}
};
let result = tokio::task::spawn_blocking(move || {
let config = apm_core::config::Config::load(&root)?;
let author = apm_core::config::resolve_identity(&root);
let mut _warnings = Vec::new();
apm_core::ticket::create(
&root,
&config,
title,
author,
None,
None,
false,
section_sets,
epic,
target_branch,
depends_on,
None,
&mut _warnings,
)
})
.await?;
match result {
Ok(ticket) => {
let has_open_questions = !extract_section(&ticket.body, "Open questions").trim().is_empty();
let has_pending_amendments = extract_section(&ticket.body, "Amendment requests").contains("- [ ]");
let mut fm = ticket.frontmatter;
let owner = fm.owner.take();
let response = TicketResponse {
frontmatter: fm,
body: ticket.body,
has_open_questions,
has_pending_amendments,
blocking_deps: vec![],
owner,
};
Ok((StatusCode::CREATED, Json(response)).into_response())
}
Err(e) => Ok((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": e.to_string()})),
)
.into_response()),
}
}