mlua_swarm_server/
issues.rs1use axum::{
12 extract::{Path, State},
13 http::StatusCode,
14 response::IntoResponse,
15 routing::{get, post},
16 Json, Router,
17};
18use mlua_swarm::blueprint::store::BlueprintId;
19use mlua_swarm::store::issue::{IssueId, IssuePayload, IssueStatus, IssueStore, IssueStoreError};
20use serde::{Deserialize, Serialize};
21use std::sync::Arc;
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct PostIssueRequest {
26 pub blueprint_id: String,
28 pub intent: String,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct PostIssueResponse {
35 pub issue_id: String,
37 pub status: String,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct GetIssueResponse {
44 pub issue_id: String,
46 pub status: String, pub blueprint_id: Option<String>,
50 pub intent: Option<String>,
52 pub reason: Option<String>,
54 pub new_version: Option<String>,
56}
57
58pub fn build_issues_router(store: Arc<dyn IssueStore>) -> Router {
63 Router::new()
64 .route("/v1/issues", post(post_issue))
65 .route("/v1/issues/:issue_id", get(get_issue))
66 .with_state(store)
67}
68
69async fn post_issue(
70 State(store): State<Arc<dyn IssueStore>>,
71 Json(req): Json<PostIssueRequest>,
72) -> Result<(StatusCode, Json<PostIssueResponse>), (StatusCode, String)> {
73 if req.intent.trim().is_empty() {
74 return Err((StatusCode::BAD_REQUEST, "intent must be non-empty".into()));
75 }
76 let issue_id = format!("h-{}", uuid::Uuid::new_v4());
77 let payload = IssuePayload {
78 issue_id: IssueId::new(issue_id.clone()),
79 blueprint_id: BlueprintId::new(req.blueprint_id),
80 intent: req.intent,
81 };
82 store
83 .create(payload)
84 .await
85 .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
86 Ok((
87 StatusCode::CREATED,
88 Json(PostIssueResponse {
89 issue_id,
90 status: "pending".into(),
91 }),
92 ))
93}
94
95async fn get_issue(
96 State(store): State<Arc<dyn IssueStore>>,
97 Path(issue_id): Path<String>,
98) -> Result<Json<GetIssueResponse>, (StatusCode, String)> {
99 let id = IssueId::new(issue_id.clone());
100 let status = match store.status(&id).await {
101 Ok(s) => s,
102 Err(IssueStoreError::NotFound(_)) => {
103 return Err((
104 StatusCode::NOT_FOUND,
105 format!("issue not found: {issue_id}"),
106 ));
107 }
108 Err(e) => return Err((StatusCode::INTERNAL_SERVER_ERROR, e.to_string())),
109 };
110 let payload = store.get(&id).await.ok();
112
113 let (status_str, reason, new_version) = match &status {
114 IssueStatus::Pending => ("pending".to_string(), None, None),
115 IssueStatus::InFlight => ("in_flight".to_string(), None, None),
116 IssueStatus::Applied { new_version } => {
117 ("applied".to_string(), None, Some(new_version.clone()))
118 }
119 IssueStatus::Rejected { reason } => ("rejected".to_string(), Some(reason.clone()), None),
120 };
121 Ok(Json(GetIssueResponse {
122 issue_id,
123 status: status_str,
124 blueprint_id: payload
125 .as_ref()
126 .map(|p| p.blueprint_id.as_str().to_string()),
127 intent: payload.as_ref().map(|p| p.intent.clone()),
128 reason,
129 new_version,
130 }))
131}
132
133pub struct IssueStoreErrorResponse(
135 pub IssueStoreError,
137);
138
139impl IntoResponse for IssueStoreErrorResponse {
140 fn into_response(self) -> axum::response::Response {
141 (StatusCode::INTERNAL_SERVER_ERROR, self.0.to_string()).into_response()
142 }
143}