wsb-daemon 0.258.6441

wsb's daemon: the workbench, the GitHub mirror's schedule and peer continuity, for every workspace on the machine
//! The workbench's way to the machine's GitHub mirror.
//!
//! Everything GitHub is `wsb_github`: the client, the mirror in `$PRJ/.state`,
//! the schedule that keeps it current, and every read and write. One mirror
//! serves every workspace this daemon serves — GitHub belongs to accounts, not
//! to checkouts — so what is here is held by the machine and lent to each
//! workspace's state: how a refusal becomes a status code, which repositories
//! and boards a workspace has, and how a page learns that the mirror moved.

use std::sync::Arc;
use std::time::Duration;

use axum::http::StatusCode;
use axum::Json;
use tokio::sync::broadcast;

use super::handlers::sessions::ErrorResponse;
use super::state::WebState;

/// What a page is told when the mirror moves.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct GitHubChanged {
    /// Which scopes changed — `gists`, or `issues:<owner>/<repo>`. Empty means
    /// "anything may have": re-read whatever is shown.
    pub scopes: Vec<String>,
}

/// The machine's GitHub.
pub struct GitHub {
    pub prj: wsb_prj::Prj,
    pub service: wsb_github::Service,
    /// Every change to the mirror, by whichever process made it.
    pub changed_tx: broadcast::Sender<GitHubChanged>,
}

impl GitHub {
    /// The mirror of the `$PRJ` the daemon was started under, or why there is
    /// none — `PRJ` unset, or a mirror that cannot be opened. Not a reason to
    /// refuse to serve anything else: every GitHub route says it instead.
    pub fn open() -> Result<Arc<Self>, String> {
        let prj = wsb_prj::Prj::from_env().map_err(|e| e.to_string())?;
        let mirror = wsb_github::Mirror::of(&prj).map_err(|e| format!("{e:#}"))?;
        let (changed_tx, _) = broadcast::channel(256);
        Ok(Arc::new(Self {
            prj,
            service: wsb_github::Service::new(mirror),
            changed_tx,
        }))
    }
}

/// How often the change log is read. A write made anywhere on the machine —
/// a command line moving a card, a sync — reaches open pages this soon.
const WATCH: Duration = Duration::from_secs(2);

/// Keep the mirror current, and tell every page when it moves.
///
/// Two loops, once per machine. The keeper runs the same pass `wsb github
/// sync` runs, on the same schedule. The watcher reads the mirror's change log
/// rather than listening to the keeper, because the keeper is not the only
/// writer: a card moved from a terminal is a change too.
pub fn spawn(github: Arc<GitHub>) {
    let keeper = github.clone();
    tokio::spawn(async move {
        let prj = keeper.prj.clone();
        let mirror = keeper.service.mirror().clone();
        wsb_github::sync::keep_current(prj, mirror, |report| {
            for (scope, error) in &report.failed {
                tracing::debug!(%scope, %error, "a GitHub scope could not be refreshed");
            }
            for problem in &report.problems {
                tracing::debug!(%problem, "what to mirror could not be found out");
            }
        })
        .await;
    });

    tokio::spawn(async move {
        let mirror = github.service.mirror().clone();
        let read = mirror.clone();
        let mut seen = match tokio::task::spawn_blocking(move || read.latest_change()).await {
            Ok(Ok(seen)) => seen,
            Ok(Err(error)) => {
                tracing::warn!(%error, "the GitHub mirror's change log cannot be read; pages will not be told it moved");
                return;
            }
            Err(error) => {
                tracing::warn!(%error, "reading the GitHub mirror's change log failed");
                return;
            }
        };
        loop {
            tokio::time::sleep(WATCH).await;
            let read = mirror.clone();
            let since = seen;
            let changes = match tokio::task::spawn_blocking(move || read.changes_since(since)).await {
                Ok(Ok(changes)) => changes,
                Ok(Err(error)) => {
                    tracing::debug!(%error, "the GitHub mirror's change log could not be read");
                    continue;
                }
                Err(error) => {
                    tracing::debug!(%error, "reading the GitHub mirror's change log failed");
                    continue;
                }
            };
            match changes {
                wsb_github::mirror::Changes::Moved { scopes, latest } => {
                    seen = latest;
                    if !scopes.is_empty() {
                        let _ = github.changed_tx.send(GitHubChanged { scopes });
                    }
                }
                wsb_github::mirror::Changes::Lost { latest } => {
                    seen = latest;
                    let _ = github.changed_tx.send(GitHubChanged { scopes: Vec::new() });
                }
            }
        }
    });
}

pub type Refusal = (StatusCode, Json<ErrorResponse>);

fn refusal(status: StatusCode, error: impl Into<String>) -> Refusal {
    (status, Json(ErrorResponse { error: error.into() }))
}

/// This workspace's way to the machine's GitHub, or the refusal that says why
/// there is none.
pub fn of(state: &WebState) -> Result<&GitHub, Refusal> {
    state
        .github
        .as_ref()
        .map(|github| github.as_ref())
        .map_err(|reason| {
            refusal(
                StatusCode::SERVICE_UNAVAILABLE,
                format!("GitHub is unavailable: {reason}"),
            )
        })
}

/// A client with the machine's token — the GitHub CLI's login, else
/// `GITHUB_TOKEN` — or the refusal that says how to log in.
pub fn client() -> Result<wsb_github::GitHubClient, Refusal> {
    wsb_github::token::resolve()
        .map(|token| wsb_github::GitHubClient::new(token.value()))
        .map_err(|no_token| refusal(StatusCode::UNAUTHORIZED, no_token.to_string()))
}

/// What a failed operation answers with.
pub fn failure(failure: wsb_github::Failure) -> Refusal {
    let status = match &failure {
        wsb_github::Failure::Invalid(_) => StatusCode::BAD_REQUEST,
        wsb_github::Failure::NoToken(_) => StatusCode::UNAUTHORIZED,
        other if other.is_authorization() => StatusCode::UNAUTHORIZED,
        wsb_github::Failure::GitHub(_) => StatusCode::BAD_GATEWAY,
        wsb_github::Failure::Mirror(_) => StatusCode::INTERNAL_SERVER_ERROR,
    };
    let message = match &failure {
        // Its own words say how to log in.
        wsb_github::Failure::NoToken(message) => message.clone(),
        _ if status == StatusCode::UNAUTHORIZED => {
            "GitHub refused the token: it is invalid or lacks the access this needs".to_string()
        }
        other => other.to_string(),
    };
    refusal(status, message)
}

fn internal(error: impl std::fmt::Display) -> Refusal {
    refusal(StatusCode::INTERNAL_SERVER_ERROR, error.to_string())
}

/// The GitHub repositories of this workspace's checkout, with the commit each
/// is checked out at.
pub async fn repositories(state: &WebState) -> Result<Vec<wsb_github::checkout::Repository>, Refusal> {
    let root = state.workspace_root.clone();
    tokio::task::spawn_blocking(move || wsb_github::checkout::repositories(&root))
        .await
        .map_err(|e| internal(format!("join error: {e}")))?
        .map_err(|e| internal(format!("{e:#}")))
}

/// The distinct `owner/repo` names of this workspace's checkout.
pub async fn repository_names(state: &WebState) -> Result<Vec<String>, Refusal> {
    Ok(repositories(state)
        .await?
        .into_iter()
        .map(|repository| repository.name)
        .collect::<std::collections::BTreeSet<_>>()
        .into_iter()
        .collect())
}

/// The boards this workspace's project references.
///
/// A workspace is a worktree of a project under `$PRJ`, and the project keeps
/// the list in `$PRJ/.state/projects/<project>/github.json`. A workspace that is not
/// under `$PRJ` is no project's, so it references none — every board is
/// still listed, none of them as its own. Failing to read where the workspace
/// is, or the project's list, is refused.
pub fn board_references(github: &GitHub, state: &WebState) -> Result<Vec<wsb_github::boards::BoardRef>, Refusal> {
    let location = match github.prj.locate(&state.workspace_root) {
        Ok(location) => location,
        Err(wsb_prj::PrjError::Outside { .. }) => return Ok(Vec::new()),
        Err(e) => return Err(internal(format!("cannot tell which project this workspace is: {e}"))),
    };
    wsb_github::boards::read(&location.project)
        .map(|declared| declared.boards)
        .map_err(|e| internal(format!("{e:#}")))
}

/// Run a mirror read off the async runtime, as one answer or one refusal.
pub async fn blocking<T: Send + 'static>(
    work: impl FnOnce() -> wsb_github::service::Outcome<T> + Send + 'static,
) -> Result<T, Refusal> {
    tokio::task::spawn_blocking(work)
        .await
        .map_err(|e| internal(format!("join error: {e}")))?
        .map_err(failure)
}

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

    #[test]
    fn refusals_keep_their_meaning() {
        let (status, _) = failure(wsb_github::Failure::Invalid("no title".to_string()));
        assert_eq!(status, StatusCode::BAD_REQUEST);
        let (status, body) = failure(wsb_github::Failure::GitHub(anyhow::anyhow!(
            "failed with GitHub API status 401"
        )));
        assert_eq!(status, StatusCode::UNAUTHORIZED);
        assert!(body.0.error.contains("refused the token"), "{}", body.0.error);
        let (status, body) = failure(wsb_github::Failure::GitHub(anyhow::anyhow!("connection reset")));
        assert_eq!(status, StatusCode::BAD_GATEWAY);
        assert_eq!(body.0.error, "connection reset");
        let (status, _) = failure(wsb_github::Failure::Mirror(anyhow::anyhow!("disk full")));
        assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
        let (status, body) = failure(wsb_github::Failure::NoToken("log in with `gh auth login`".to_string()));
        assert_eq!(status, StatusCode::UNAUTHORIZED);
        assert_eq!(body.0.error, "log in with `gh auth login`", "a missing token says how to log in");
    }
}