wsb 0.93.391653

Workspace - A tool suite for file operations, version management, and development workflow automation
Documentation
use axum::{
    extract::{Path, Query, State},
    http::StatusCode,
    Json,
};
use serde::Deserialize;

use super::sessions::ErrorResponse;
use crate::web::github::{self, CreateIssueRequest, UpdateIssueRequest};
use crate::web::state::WebState;
use super::fleet::{collect_fleet_repos, workspace_root};

fn no_github() -> (StatusCode, Json<ErrorResponse>) {
    (
        StatusCode::UNAUTHORIZED,
        Json(ErrorResponse {
            error: "GitHub token not configured".to_string(),
        }),
    )
}

fn github_error(e: anyhow::Error) -> (StatusCode, Json<ErrorResponse>) {
    let message = e.to_string();
    if message.contains("status 401") || message.contains("status 403") {
        return (
            StatusCode::UNAUTHORIZED,
            Json(ErrorResponse {
                error: "GitHub token is invalid or lacks required access".to_string(),
            }),
        );
    }
    (
        StatusCode::BAD_GATEWAY,
        Json(ErrorResponse {
            error: message,
        }),
    )
}

#[derive(Deserialize)]
pub struct ListIssuesQuery {
    pub state: Option<String>,
    pub repo: Option<String>,
}

/// GET /api/v1/issues — list across all fleet repos
pub async fn list_issues(
    State(state): State<WebState>,
    Query(params): Query<ListIssuesQuery>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> {
    let github = state.github.lock().await;
    let client = github.as_ref().ok_or_else(no_github)?;

    let issue_state = params.state.as_deref().unwrap_or("open");
    let repo_root = workspace_root()?;
    let mut all_repos = collect_fleet_repos(&repo_root)
        .map_err(|e| {
            (
                StatusCode::INTERNAL_SERVER_ERROR,
                Json(ErrorResponse {
                    error: e.to_string(),
                }),
            )
        })?
        .into_iter()
        .map(|repo| (repo.github.owner, repo.github.repo))
        .collect::<Vec<_>>();

    // Filter by repo if specified
    if let Some(ref filter_repo) = params.repo {
        all_repos.retain(|(o, r)| format!("{}/{}", o, r) == *filter_repo);
    }

    let mut all_issues: Vec<serde_json::Value> = Vec::new();
    let mut failures: Vec<serde_json::Value> = Vec::new();

    for (owner, repo) in &all_repos {
        match client.list_issues(owner, repo, issue_state).await {
            Ok(issues) => {
                for issue in issues {
                    let mut val = serde_json::to_value(&issue).unwrap();
                    val.as_object_mut()
                        .unwrap()
                        .insert("repo".to_string(), serde_json::json!(format!("{}/{}", owner, repo)));
                    all_issues.push(val);
                }
            }
            Err(error) => {
                let (status, body) = github_error(error);
                if status == StatusCode::UNAUTHORIZED {
                    return Err((status, body));
                }
                failures.push(serde_json::json!({
                    "repo": format!("{}/{}", owner, repo),
                    "error": body.0.error,
                }));
            }
        }
    }

    // Sort by updated_at desc
    all_issues.sort_by(|a, b| {
        let a_date = a.get("updated_at").and_then(|d| d.as_str()).unwrap_or("");
        let b_date = b.get("updated_at").and_then(|d| d.as_str()).unwrap_or("");
        b_date.cmp(a_date)
    });

    Ok(Json(serde_json::json!({
        "issues": all_issues,
        "failures": failures,
    })))
}

/// GET /api/v1/issues/:owner/:repo
pub async fn list_repo_issues(
    State(state): State<WebState>,
    Path((owner, repo)): Path<(String, String)>,
    Query(params): Query<ListIssuesQuery>,
) -> Result<Json<Vec<github::GitHubIssue>>, (StatusCode, Json<ErrorResponse>)> {
    let github = state.github.lock().await;
    let client = github.as_ref().ok_or_else(no_github)?;

    let issue_state = params.state.as_deref().unwrap_or("open");
    let issues = client
        .list_issues(&owner, &repo, issue_state)
        .await
        .map_err(github_error)?;
    Ok(Json(issues))
}

/// POST /api/v1/issues/:owner/:repo
pub async fn create_issue(
    State(state): State<WebState>,
    Path((owner, repo)): Path<(String, String)>,
    Json(body): Json<CreateIssueRequest>,
) -> Result<Json<github::GitHubIssue>, (StatusCode, Json<ErrorResponse>)> {
    let github = state.github.lock().await;
    let client = github.as_ref().ok_or_else(no_github)?;

    let issue = client
        .create_issue(&owner, &repo, &body)
        .await
        .map_err(github_error)?;
    Ok(Json(issue))
}

/// PATCH /api/v1/issues/:owner/:repo/:number
pub async fn update_issue(
    State(state): State<WebState>,
    Path((owner, repo, number)): Path<(String, String, i64)>,
    Json(body): Json<UpdateIssueRequest>,
) -> Result<Json<github::GitHubIssue>, (StatusCode, Json<ErrorResponse>)> {
    let github = state.github.lock().await;
    let client = github.as_ref().ok_or_else(no_github)?;

    let issue = client
        .update_issue(&owner, &repo, number, &body)
        .await
        .map_err(github_error)?;
    Ok(Json(issue))
}

#[derive(Deserialize)]
pub struct CommentRequest {
    pub body: String,
}

/// GET /api/v1/issues/:owner/:repo/:number/comments
pub async fn list_comments(
    State(state): State<WebState>,
    Path((owner, repo, number)): Path<(String, String, i64)>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> {
    let github = state.github.lock().await;
    let client = github.as_ref().ok_or_else(no_github)?;

    let comments = client
        .list_comments(&owner, &repo, number)
        .await
        .map_err(github_error)?;
    Ok(Json(serde_json::json!({ "comments": comments })))
}

/// POST /api/v1/issues/:owner/:repo/:number/comments
pub async fn add_comment(
    State(state): State<WebState>,
    Path((owner, repo, number)): Path<(String, String, i64)>,
    Json(body): Json<CommentRequest>,
) -> Result<Json<github::GitHubComment>, (StatusCode, Json<ErrorResponse>)> {
    let github = state.github.lock().await;
    let client = github.as_ref().ok_or_else(no_github)?;

    let comment = client
        .add_comment(&owner, &repo, number, &body.body)
        .await
        .map_err(github_error)?;
    Ok(Json(comment))
}

#[cfg(test)]
mod tests {
    use super::github_error;
    use axum::http::StatusCode;

    #[test]
    fn list_issues_response_shape_wraps_issues() {
        let payload = serde_json::json!({
            "issues": [{
                "number": 1,
                "title": "Broken contract",
                "repo": "example/repo"
            }],
            "failures": [{
                "repo": "example/bad-repo",
                "error": "GitHub API error listing issues"
            }]
        });

        assert!(payload.get("issues").unwrap().is_array());
        assert!(payload.get("failures").unwrap().is_array());
    }

    #[test]
    fn list_comments_response_shape_wraps_comments() {
        let payload = serde_json::json!({
            "comments": [{
                "id": 1,
                "body": "Looks good"
            }]
        });

        assert!(payload.get("comments").unwrap().is_array());
    }

    #[test]
    fn github_auth_errors_map_to_unauthorized() {
        let (status, body) = github_error(anyhow::anyhow!("failed with GitHub API status 401"));
        assert_eq!(status, StatusCode::UNAUTHORIZED);
        assert_eq!(body.0.error, "GitHub token is invalid or lacks required access");
    }
}