wsb-daemon 0.258.6441

wsb's daemon: the workbench, the GitHub mirror's schedule and peer continuity, for every workspace on the machine
//! Project boards: every board of the token's account and its organisations,
//! which of them this workspace's project references, what is on them, and
//! everything a person does to one.
//!
//! Reads come from the machine's mirror. Writes go to GitHub FIRST and are
//! mirrored from the item GitHub hands back — `wsb_github::Service` is the one
//! implementation of that, and this is how a page reaches it.

use axum::extract::{Path, State};
use axum::Json;
use serde::Deserialize;
use serde_json::json;

use crate::web::github::{self, Refusal};
use crate::web::state::WebState;
use wsb_github::client::{GitHubProjectItem, ProjectFieldWrite};

/// Every board the mirror holds for this person, owner by owner, with the
/// ones this workspace's project references marked, how current each owner's
/// list is, and why anything could not be listed.
pub async fn list_projects(State(state): State<WebState>) -> Result<Json<serde_json::Value>, Refusal> {
    let github = github::of(&state)?;
    let references = github::board_references(github, &state)?;
    let service = github.service.clone();
    let all = github::blocking(move || service.all_boards(&references)).await?;
    Ok(Json(json!({
        "projects": all.boards,
        "owners": all.owners,
        "referenced": all.referenced,
        "account": all.account,
        "problems": all.problems,
    })))
}

/// Ask GitHub about the boards now — who the token is, every owner's list,
/// the cards being kept — and say what moved. The rest of the machine's
/// mirror is left to its schedule.
pub async fn refresh_projects(
    State(state): State<WebState>,
) -> Result<Json<wsb_github::sync::SyncReport>, Refusal> {
    let github = github::of(&state)?;
    let references = github::board_references(github, &state)?;
    let client = match github::client() {
        Ok(client) => client,
        // No login is a result to show, not a refusal: nothing was asked, and
        // the boards already held are still the answer.
        Err(_) => {
            return Ok(Json(wsb_github::sync::SyncReport {
                no_token: true,
                ..Default::default()
            }))
        }
    };
    Ok(Json(github.service.refresh_boards(&client, &references).await))
}

/// One board's cards.
///
/// Cards are kept current by the machine's schedule once they are held. A
/// board whose cards have never been read — one opened from the list for the
/// first time, or a reference added a moment ago — is read now, because
/// somebody just opened it.
pub async fn project_items(
    State(state): State<WebState>,
    Path(project): Path<String>,
) -> Result<Json<serde_json::Value>, Refusal> {
    let github = github::of(&state)?;
    let service = github.service.clone();
    let board = project.clone();
    let mut cards = github::blocking(move || service.cards(&board)).await?;
    if !cards.sync.ever_synced {
        if let Ok(client) = github::client() {
            let board = known_board(&state, &project).await?;
            let report = github.service.refresh_board(&client, &board, true).await;
            if let Some((scope, error)) = report.failed.first() {
                tracing::debug!(%scope, %error, "a board opened for the first time could not be read");
            }
            let service = github.service.clone();
            let board = project.clone();
            cards = github::blocking(move || service.cards(&board)).await?;
        }
    }
    Ok(Json(json!({ "items": cards.items, "sync": cards.sync })))
}

/// The board with this node id, among every board the mirror lists.
async fn known_board(state: &WebState, id: &str) -> Result<wsb_github::client::GitHubProject, Refusal> {
    let github = github::of(state)?;
    let references = github::board_references(github, state)?;
    let service = github.service.clone();
    let all = github::blocking(move || service.all_boards(&references)).await?;
    all.boards
        .into_iter()
        .find(|board| board.id == id)
        .ok_or_else(|| {
            github::failure(wsb_github::Failure::Invalid(format!(
                "board {id} is not one the mirror lists for this account or its organisations"
            )))
        })
}

/// Which board a write is for, checked rather than trusted.
///
/// Every mutation names the project in its body as well as its path. A write
/// that took only the path could be pointed at a card on another board by a
/// caller that got one of the two wrong — and a card moved on the wrong board
/// is not something the answer would reveal.
fn same_board(path: &str, body: &str) -> Result<(), Refusal> {
    if path == body {
        return Ok(());
    }
    Err(github::failure(wsb_github::Failure::Invalid(format!(
        "the path names project {path} and the body names {body}"
    ))))
}

fn item(card: GitHubProjectItem) -> Json<serde_json::Value> {
    Json(json!({ "item": card }))
}

#[derive(Debug, Deserialize)]
pub struct FieldRequest {
    /// The board, by node id.
    pub project: String,
    /// The field being written.
    pub field: String,
    /// The value, or `null` to take the field's value off the card: an empty
    /// text IS a value, and clearing is its own act.
    pub value: Option<ProjectFieldWrite>,
}

/// Set or clear one field on one card.
pub async fn set_field(
    State(state): State<WebState>,
    Path((project, card)): Path<(String, String)>,
    Json(body): Json<FieldRequest>,
) -> Result<Json<serde_json::Value>, Refusal> {
    same_board(&project, &body.project)?;
    let github = github::of(&state)?;
    let client = github::client()?;
    github
        .service
        .set_field(&client, &project, &card, &body.field, body.value.as_ref())
        .await
        .map(item)
        .map_err(github::failure)
}

#[derive(Debug, Deserialize)]
pub struct PositionRequest {
    pub project: String,
    /// The card this one lands behind. `null` puts it first.
    pub after: Option<String>,
}

/// Order one card against the others.
pub async fn set_position(
    State(state): State<WebState>,
    Path((project, card)): Path<(String, String)>,
    Json(body): Json<PositionRequest>,
) -> Result<Json<serde_json::Value>, Refusal> {
    same_board(&project, &body.project)?;
    let github = github::of(&state)?;
    let client = github::client()?;
    github
        .service
        .move_card(&client, &project, &card, body.after.as_deref())
        .await
        .map(item)
        .map_err(github::failure)
}

#[derive(Debug, Deserialize)]
pub struct ArchiveRequest {
    pub project: String,
    /// Archived, or brought back — stated rather than toggled.
    pub archived: bool,
}

/// Archive a card, or bring it back.
pub async fn set_archived(
    State(state): State<WebState>,
    Path((project, card)): Path<(String, String)>,
    Json(body): Json<ArchiveRequest>,
) -> Result<Json<serde_json::Value>, Refusal> {
    same_board(&project, &body.project)?;
    let github = github::of(&state)?;
    let client = github::client()?;
    github
        .service
        .set_archived(&client, &project, &card, body.archived)
        .await
        .map(item)
        .map_err(github::failure)
}

#[derive(Debug, Deserialize)]
pub struct DraftRequest {
    pub project: String,
    /// What the card points at — the draft itself, not the card. A draft is
    /// edited by its content id and moved by its item id.
    pub content: String,
    pub title: String,
    #[serde(default)]
    pub body: String,
}

/// Rewrite a draft card's title and body.
pub async fn edit_draft(
    State(state): State<WebState>,
    Path((project, card)): Path<(String, String)>,
    Json(body): Json<DraftRequest>,
) -> Result<Json<serde_json::Value>, Refusal> {
    same_board(&project, &body.project)?;
    let github = github::of(&state)?;
    let client = github::client()?;
    github
        .service
        .edit_draft(&client, &project, &card, &body.content, &body.title, &body.body)
        .await
        .map(item)
        .map_err(github::failure)
}

#[derive(Debug, Deserialize)]
#[serde(untagged)]
pub enum AddRequest {
    /// An issue or pull request that already exists, by its URL.
    Existing { project: String, url: String },
    /// A card that lives only on the board.
    Draft {
        project: String,
        title: String,
        #[serde(default)]
        body: String,
    },
}

impl AddRequest {
    fn project(&self) -> &str {
        match self {
            Self::Existing { project, .. } | Self::Draft { project, .. } => project,
        }
    }
}

/// Put something new on the board.
pub async fn add_item(
    State(state): State<WebState>,
    Path(project): Path<String>,
    Json(body): Json<AddRequest>,
) -> Result<Json<serde_json::Value>, Refusal> {
    same_board(&project, body.project())?;
    let github = github::of(&state)?;
    let client = github::client()?;
    let added = match &body {
        AddRequest::Existing { url, .. } => github.service.add_existing(&client, &project, url).await,
        AddRequest::Draft { title, body, .. } => {
            github.service.add_draft(&client, &project, title, body).await
        }
    };
    added.map(item).map_err(github::failure)
}

#[derive(Debug, Deserialize)]
pub struct RemoveRequest {
    pub project: String,
}

/// Take a card off the board. The issue itself is untouched.
pub async fn remove_item(
    State(state): State<WebState>,
    Path((project, card)): Path<(String, String)>,
    Json(body): Json<RemoveRequest>,
) -> Result<Json<serde_json::Value>, Refusal> {
    same_board(&project, &body.project)?;
    let github = github::of(&state)?;
    let client = github::client()?;
    github
        .service
        .remove_card(&client, &project, &card)
        .await
        .map_err(github::failure)?;
    Ok(Json(json!({ "removed": card })))
}

#[derive(Debug, Deserialize)]
pub struct AddColumnRequest {
    pub project: String,
    pub field: String,
    pub name: String,
    pub color: String,
    pub description: String,
}

/// Add a column option to the board's live single-select field.
pub async fn add_column(
    State(state): State<WebState>,
    Path(project): Path<String>,
    Json(body): Json<AddColumnRequest>,
) -> Result<Json<serde_json::Value>, Refusal> {
    same_board(&project, &body.project)?;
    let github = github::of(&state)?;
    let client = github::client()?;
    let (board, items) = github
        .service
        .add_column(&client, &project, &body.field, &body.name, &body.color, &body.description)
        .await
        .map_err(github::failure)?;
    Ok(Json(json!({ "project": board, "items": items })))
}

#[derive(Debug, Deserialize)]
pub struct RemoveColumnRequest {
    pub project: String,
    pub field: String,
}

/// Remove one column option. GitHub clears that value from affected cards;
/// every card is read again before the mirror changes.
pub async fn remove_column(
    State(state): State<WebState>,
    Path((project, option)): Path<(String, String)>,
    Json(body): Json<RemoveColumnRequest>,
) -> Result<Json<serde_json::Value>, Refusal> {
    same_board(&project, &body.project)?;
    let github = github::of(&state)?;
    let client = github::client()?;
    let (board, items) = github
        .service
        .remove_column(&client, &project, &body.field, &option)
        .await
        .map_err(github::failure)?;
    Ok(Json(json!({ "project": board, "items": items })))
}

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

    #[test]
    fn a_write_naming_two_boards_is_refused() {
        assert!(same_board("PVT_1", "PVT_1").is_ok());
        let (status, body) = same_board("PVT_1", "PVT_2").expect_err("two boards");
        assert_eq!(status, axum::http::StatusCode::BAD_REQUEST);
        assert!(body.0.error.contains("PVT_1") && body.0.error.contains("PVT_2"));
    }
}