use std::collections::BTreeMap;
use std::sync::Arc;
use axum::extract::{Path, State};
use axum::response::sse::{Event, KeepAlive, Sse};
use axum::routing::{get, post};
use axum::{Json, Router};
use serde::Deserialize;
use serde_json::json;
use crate::machine::MachineState;
use crate::web::github::{self, Refusal};
use wsb_github::boards::BoardRef;
use wsb_github::client::{GitHubProjectItem, ProjectFieldWrite};
pub fn routes() -> Router<Arc<MachineState>> {
Router::new()
.route("/api/v1/boards", get(list_boards))
.route("/api/v1/boards/refresh", post(refresh_boards))
.route("/api/v1/github/events", get(events))
.route("/api/v1/boards/:board/items", get(board_items).post(add_item))
.route("/api/v1/boards/:board/items/:item", axum::routing::delete(remove_item))
.route("/api/v1/boards/:board/items/:item/field", post(set_field))
.route("/api/v1/boards/:board/items/:item/position", post(set_position))
.route("/api/v1/boards/:board/items/:item/archived", post(set_archived))
.route("/api/v1/boards/:board/items/:item/draft", post(edit_draft))
.route("/api/v1/boards/:board/columns", post(add_column))
.route("/api/v1/boards/:board/columns/:option", axum::routing::delete(remove_column))
}
#[derive(Debug, Default)]
struct Using {
boards: Vec<BoardRef>,
projects: BTreeMap<String, Vec<String>>,
problems: Vec<String>,
}
fn projects_using() -> Using {
let mut using = Using::default();
let workshop = match wsb_prj::Workshop::from_env() {
Ok(workshop) => workshop,
Err(wsb_prj::WorkshopError::NotSet) => return using,
Err(error) => {
using.problems.push(error.to_string());
return using;
}
};
let workspaces = match workshop.workspaces() {
Ok(workspaces) => workspaces,
Err(error) => {
using.problems.push(error.to_string());
return using;
}
};
for workspace in workspaces {
match wsb_github::boards::read(&workspace.main_worktree()) {
Ok(boards) => {
for board in boards {
let projects = using.projects.entry(board.to_string()).or_default();
if projects.is_empty() {
using.boards.push(board);
}
projects.push(workspace.name().to_string());
}
}
Err(error) => using.problems.push(format!("{}: {error:#}", workspace.name())),
}
}
using
}
async fn list_boards(State(machine): State<Arc<MachineState>>) -> Result<Json<serde_json::Value>, Refusal> {
let github = github::of_machine(&machine)?;
let service = github.service.clone();
let (using, all) = github::blocking(move || {
let using = projects_using();
service.all_boards(&using.boards).map(|all| (using, all))
})
.await?;
let mut problems = using.problems;
problems.extend(all.problems);
Ok(Json(json!({
"boards": all.boards,
"recent": all.recent,
"owners": all.owners,
"projects": using.projects,
"account": all.account,
"problems": problems,
})))
}
async fn refresh_boards(
State(machine): State<Arc<MachineState>>,
) -> Result<Json<wsb_github::sync::SyncReport>, Refusal> {
let github = github::of_machine(&machine)?;
let client = match github::client() {
Ok(client) => client,
Err(_) => {
return Ok(Json(wsb_github::sync::SyncReport {
no_token: true,
..Default::default()
}))
}
};
let boards = github::blocking(|| Ok(projects_using().boards)).await?;
Ok(Json(github.service.refresh_boards(&client, &boards).await))
}
async fn events(
State(machine): State<Arc<MachineState>>,
) -> Result<Sse<impl futures::Stream<Item = Result<Event, std::convert::Infallible>>>, Refusal> {
let mut changed = github::of_machine(&machine)?.changed_tx.subscribe();
let stream = async_stream::stream! {
yield Ok(Event::default().event("connected").data("{}"));
loop {
let scopes = match changed.recv().await {
Ok(change) => change,
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => github::GitHubChanged { scopes: Vec::new() },
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
};
if let Ok(json) = serde_json::to_string(&scopes) {
yield Ok(Event::default().event("github").data(json));
}
}
};
Ok(Sse::new(stream).keep_alive(KeepAlive::default()))
}
async fn board_items(
State(machine): State<Arc<MachineState>>,
Path(board): Path<String>,
) -> Result<Json<serde_json::Value>, Refusal> {
let github = github::of_machine(&machine)?;
let service = github.service.clone();
let id = board.clone();
let mut cards = github::blocking(move || service.cards(&id)).await?;
if !cards.sync.ever_synced {
if let Ok(client) = github::client() {
let known = known_board(&machine, &board).await?;
let report = github.service.refresh_board(&client, &known, 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 id = board.clone();
cards = github::blocking(move || service.cards(&id)).await?;
}
}
Ok(Json(json!({ "items": cards.items, "sync": cards.sync })))
}
async fn known_board(machine: &MachineState, id: &str) -> Result<wsb_github::client::GitHubProject, Refusal> {
let github = github::of_machine(machine)?;
let service = github.service.clone();
let all = github::blocking(move || service.all_boards(&[])).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"
)))
})
}
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 board {path} and the body names {body}"
))))
}
fn item(card: GitHubProjectItem) -> Json<serde_json::Value> {
Json(json!({ "item": card }))
}
#[derive(Debug, Deserialize)]
pub struct FieldRequest {
pub project: String,
pub field: String,
pub value: Option<ProjectFieldWrite>,
}
async fn set_field(
State(machine): State<Arc<MachineState>>,
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_machine(&machine)?;
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,
pub after: Option<String>,
}
async fn set_position(
State(machine): State<Arc<MachineState>>,
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_machine(&machine)?;
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,
pub archived: bool,
}
async fn set_archived(
State(machine): State<Arc<MachineState>>,
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_machine(&machine)?;
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,
pub content: String,
pub title: String,
#[serde(default)]
pub body: String,
}
async fn edit_draft(
State(machine): State<Arc<MachineState>>,
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_machine(&machine)?;
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 {
Existing { project: String, url: String },
Draft {
project: String,
title: String,
#[serde(default)]
body: String,
},
}
impl AddRequest {
fn project(&self) -> &str {
match self {
Self::Existing { project, .. } | Self::Draft { project, .. } => project,
}
}
}
async fn add_item(
State(machine): State<Arc<MachineState>>,
Path(project): Path<String>,
Json(body): Json<AddRequest>,
) -> Result<Json<serde_json::Value>, Refusal> {
same_board(&project, body.project())?;
let github = github::of_machine(&machine)?;
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,
}
async fn remove_item(
State(machine): State<Arc<MachineState>>,
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_machine(&machine)?;
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,
}
async fn add_column(
State(machine): State<Arc<MachineState>>,
Path(project): Path<String>,
Json(body): Json<AddColumnRequest>,
) -> Result<Json<serde_json::Value>, Refusal> {
same_board(&project, &body.project)?;
let github = github::of_machine(&machine)?;
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,
}
async fn remove_column(
State(machine): State<Arc<MachineState>>,
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_machine(&machine)?;
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 test20123_each_board_carries_the_projects_that_work_from_it() {
let prj = tempfile::tempdir().expect("a workshop");
let machine = tempfile::tempdir().expect("a machine folder");
let _env = wsb::test_environment::Environment::set(&[
("PRJ", &prj.path().to_string_lossy()),
(wsb::daemon::MACHINE_DIR_ENV, &machine.path().to_string_lossy()),
]);
let lists = |project: &str, boards: &[&str]| {
let main = prj.path().join(project).join("main-worktree");
std::fs::create_dir_all(&main).expect("a main worktree");
wsb::settings::ProjectSettings {
boards: boards.iter().map(|board| board.to_string()).collect(),
..Default::default()
}
.save(&main)
.expect("the settings are written");
};
lists("alpha", &["acme/1", "acme/2"]);
lists("beta", &["acme/2"]);
lists("gamma", &[]);
let broken = prj.path().join("delta/main-worktree/.wsb");
std::fs::create_dir_all(&broken).expect("a settings folder");
std::fs::write(broken.join("project.json"), "{ not json").expect("broken settings");
let using = projects_using();
assert_eq!(
using.boards.iter().map(ToString::to_string).collect::<Vec<_>>(),
["acme/1", "acme/2"],
"each listed board once"
);
assert_eq!(using.projects["acme/1"], ["alpha"]);
assert_eq!(using.projects["acme/2"], ["alpha", "beta"]);
assert_eq!(using.projects.len(), 2, "{:?}", using.projects);
assert_eq!(using.problems.len(), 1, "{:?}", using.problems);
assert!(using.problems[0].starts_with("delta: "), "{:?}", using.problems);
}
#[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"));
}
}