wsb 0.227.4750

Workspace - A tool suite for file operations, version management, and development workflow automation
Documentation
//! Issues: read from the machine's mirror, written through GitHub.
//!
//! Every read answers from the mirror — instantly, offline, with no token —
//! and carries how current each repository's copy is, so a page can say "as
//! of ten minutes ago" and "could not be refreshed" rather than implying it
//! just asked. Refreshing is an action (`POST /issues/refresh`) and a
//! schedule, never a side effect of looking.

use crate::prelude::*;
use axum::{
    extract::{Path, Query, State},
    Json,
};

use crate::web::github::{self, Refusal};
use crate::web::state::WebState;
use wsb_github::client::{CreateIssueRequest, GitHubComment, GitHubIssue, UpdateIssueRequest};
use wsb_github::service::IssueState;

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

fn issue_state(query: &ListIssuesQuery) -> Result<IssueState, Refusal> {
    query
        .state
        .as_deref()
        .unwrap_or("open")
        .parse()
        .map_err(|error: String| {
            (
                axum::http::StatusCode::BAD_REQUEST,
                Json(super::sessions::ErrorResponse { error }),
            )
        })
}

/// GET /api/v1/issues — every mirrored issue of this workspace's repositories.
pub async fn list_issues(
    State(state): State<WebState>,
    Query(query): Query<ListIssuesQuery>,
) -> Result<Json<wsb_github::service::IssueList>, Refusal> {
    let wanted = issue_state(&query)?;
    let github = github::of(&state)?;
    let mut repos: Vec<String> = github::repositories(&state)?
        .into_iter()
        .map(|repository| repository.name)
        .collect::<std::collections::BTreeSet<_>>()
        .into_iter()
        .collect();
    if let Some(repo) = &query.repo {
        repos.retain(|held| held == repo);
    }
    let service = github.service.clone();
    let list = tokio::task::spawn_blocking(move || service.issues(&repos, wanted))
        .await
        .map_err(|e| github::failure(wsb_github::Failure::Mirror(anyhow::anyhow!("join error: {e}"))))?
        .map_err(github::failure)?;
    Ok(Json(list))
}

/// GET /api/v1/issues/:owner/:repo — one repository's mirrored issues.
pub async fn list_repo_issues(
    State(state): State<WebState>,
    Path((owner, repo)): Path<(String, String)>,
    Query(query): Query<ListIssuesQuery>,
) -> Result<Json<serde_json::Value>, Refusal> {
    let wanted = issue_state(&query)?;
    let github = github::of(&state)?;
    let name = format!("{owner}/{repo}");
    let service = github.service.clone();
    let repos = vec![name.clone()];
    let mut list = tokio::task::spawn_blocking(move || service.issues(&repos, wanted))
        .await
        .map_err(|e| github::failure(wsb_github::Failure::Mirror(anyhow::anyhow!("join error: {e}"))))?
        .map_err(github::failure)?;
    Ok(Json(serde_json::json!({
        "repo": name,
        "issues": list.issues,
        "sync": list.sync.remove(&name),
    })))
}

/// POST /api/v1/issues/refresh — bring every project's GitHub current now.
///
/// What a refresh button means. Still conditional: a forced refresh of an
/// unchanged repository is a 304, which costs no rate limit.
pub async fn refresh_issues(
    State(state): State<WebState>,
) -> Result<Json<wsb_github::sync::SyncReport>, Refusal> {
    let github = github::of(&state)?;
    let prj = github.prj.clone();
    let (targets, token) = tokio::task::spawn_blocking(move || {
        (wsb_github::targets::Targets::of(&prj), wsb_github::token::resolve())
    })
    .await
    .map_err(|e| github::failure(wsb_github::Failure::Mirror(anyhow::anyhow!("join error: {e}"))))?;
    let targets = targets.map_err(|e| github::failure(wsb_github::Failure::Mirror(e)))?;
    let client = token.ok().map(|token| wsb_github::GitHubClient::new(token.value()));
    let report =
        wsb_github::sync::sync(github.service.mirror(), client.as_ref(), &targets, true).await;
    Ok(Json(report))
}

#[derive(Debug, Clone, Serialize)]
pub struct RootIssue {
    pub id: String,
    pub title: String,
}

/// GET /api/v1/issues/root — the workspace's own active issues, from its
/// project record rather than from GitHub.
pub async fn root_issues(
    State(state): State<WebState>,
) -> Result<Json<serde_json::Value>, Refusal> {
    let issues = load_root_issues(&state.db, &state.db_service)
        .await
        .map_err(|e| github::failure(wsb_github::Failure::Mirror(e)))?;
    Ok(Json(serde_json::json!({ "root_issues": issues })))
}

async fn load_root_issues(
    repos: &crate::persist::global::WsbRepos,
    db_service: &crate::persist::global::DatabaseService,
) -> anyhow::Result<Vec<RootIssue>> {
    let projects = repos.project_repo.list(None, None).await?;
    let Some(project) = projects.into_iter().next() else {
        return Ok(Vec::new());
    };
    let snapshot =
        crate::persist::local::database::create_context_snapshot(db_service, &project.id).await?;
    Ok(snapshot
        .active_issues
        .into_iter()
        .enumerate()
        .map(|(index, title)| RootIssue {
            id: format!("workspace-active-issue-{}", index + 1),
            title,
        })
        .collect())
}

/// 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<GitHubIssue>, Refusal> {
    let github = github::of(&state)?;
    let client = github::client()?;
    github
        .service
        .create_issue(&client, &owner, &repo, &body)
        .await
        .map(Json)
        .map_err(github::failure)
}

/// 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<GitHubIssue>, Refusal> {
    let github = github::of(&state)?;
    let client = github::client()?;
    github
        .service
        .update_issue(&client, &owner, &repo, number, &body)
        .await
        .map(Json)
        .map_err(github::failure)
}

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

/// GET /api/v1/issues/:owner/:repo/:number/comments
///
/// Opening a thread puts it in the sweep; the first open reads it now.
pub async fn list_comments(
    State(state): State<WebState>,
    Path((owner, repo, number)): Path<(String, String, i64)>,
) -> Result<Json<serde_json::Value>, Refusal> {
    let github = github::of(&state)?;
    let client = github::client().ok();
    let listed = github
        .service
        .comments(client.as_ref(), &format!("{owner}/{repo}"), number)
        .await
        .map_err(github::failure)?;
    Ok(Json(serde_json::json!({ "comments": listed.items, "sync": listed.sync })))
}

/// 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<GitHubComment>, Refusal> {
    let github = github::of(&state)?;
    let client = github::client()?;
    github
        .service
        .add_comment(&client, &owner, &repo, number, &body.body)
        .await
        .map(Json)
        .map_err(github::failure)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn an_unknown_issue_state_is_a_bad_request() {
        let refused = issue_state(&ListIssuesQuery {
            state: Some("sometimes".to_string()),
            repo: None,
        })
        .expect_err("a state nobody has");
        assert_eq!(refused.0, axum::http::StatusCode::BAD_REQUEST);
        assert!(issue_state(&ListIssuesQuery { state: None, repo: None }).is_ok(), "open is the default");
    }

    #[test]
    fn github_refusals_keep_their_meaning() {
        let (status, _) = github::failure(wsb_github::Failure::Invalid("no title".to_string()));
        assert_eq!(status, axum::http::StatusCode::BAD_REQUEST);
        let (status, body) = github::failure(wsb_github::Failure::GitHub(anyhow::anyhow!(
            "failed with GitHub API status 401"
        )));
        assert_eq!(status, axum::http::StatusCode::UNAUTHORIZED);
        assert!(body.0.error.contains("refused the token"));
        let (status, _) = github::failure(wsb_github::Failure::GitHub(anyhow::anyhow!("connection reset")));
        assert_eq!(status, axum::http::StatusCode::BAD_GATEWAY);
    }
}