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;
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct GitHubChanged {
pub scopes: Vec<String>,
}
pub struct GitHub {
pub prj: wsb_prj::Prj,
pub service: wsb_github::Service,
pub changed_tx: broadcast::Sender<GitHubChanged>,
}
impl GitHub {
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,
}))
}
}
const WATCH: Duration = Duration::from_secs(2);
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() }))
}
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}"),
)
})
}
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()))
}
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 {
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())
}
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:#}")))
}
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())
}
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:#}")))
}
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");
}
}