Skip to main content

mlua_swarm_server/
issues.rs

1//! HTTP surface for the Enhance issue axis (current design).
2//!
3//! - `POST /v1/issues` submits an issue (= `IssueStore.create`).
4//! - `GET  /v1/issues/:id` returns its status (= `IssueStore.get + status`).
5//!
6//! The backend is an `IssueStore` trait object (= the caller selects an
7//! `InMemoryIssueStore` or a persistent backend and passes it in). This
8//! replaces the pre-v0.9 `/issues` (= no `/v1/` prefix, `InMemoryIssueSource`
9//! backend).
10
11use 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/// Body for `POST /v1/issues`.
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct PostIssueRequest {
26    /// Target Blueprint the issue proposes a change against.
27    pub blueprint_id: String,
28    /// Free-text description of the desired change; must be non-empty.
29    pub intent: String,
30}
31
32/// Response for `POST /v1/issues`.
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct PostIssueResponse {
35    /// Server-minted id (`h-<uuid>`).
36    pub issue_id: String,
37    /// Always `"pending"` at creation time.
38    pub status: String,
39}
40
41/// Response for `GET /v1/issues/:id`.
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct GetIssueResponse {
44    /// Echoes the requested issue id.
45    pub issue_id: String,
46    /// One of `"pending"` / `"in_flight"` / `"applied"` / `"rejected"`.
47    pub status: String, // "pending" / "in_flight" / "applied" / "rejected"
48    /// Target Blueprint id, when the payload is still available.
49    pub blueprint_id: Option<String>,
50    /// Original intent text, when the payload is still available.
51    pub intent: Option<String>,
52    /// Rejection reason; `Some` only when `status == "rejected"`.
53    pub reason: Option<String>,
54    /// New Blueprint version produced by the change; `Some` only when `status == "applied"`.
55    pub new_version: Option<String>,
56}
57
58/// Router that provides `/v1/issues` + `/v1/issues/:id`. Callers integrate it
59/// into an existing Router via `Router::merge`. The backend is the `IssueStore`
60/// passed in as an argument (= pass in the same instance as `EnhancePipeline`
61/// via `Arc`).
62pub 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    // Fetch the payload too (return the body when status is Pending / InFlight).
111    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
133/// Wraps `IssueStoreError` into a 500 response (safety net for future extension).
134pub struct IssueStoreErrorResponse(
135    /// The underlying store error being wrapped.
136    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}