From 3c6b455da085452b11d44ab09c9e6d8ee36aa764 Mon Sep 17 00:00:00 2001 From: Brandon Oxendine Date: Thu, 5 Nov 2026 16:44:27 +0100 Subject: [PATCH] desktop: Add board commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The port lists, creates and renames boards, edits their columns, and applies a batch of card moves and removals as one op, so a burst of changes costs one update of the signed refs. Changes the board would ignore are refused before writing and reported back, and boards are only written on the test network or in private repositories for now. Co-authored-by: Rūdolfs Ošiņš --- desktop/Cargo.lock | 1 + .../crates/radicle-tauri/src/commands/cob.rs | 1 + .../radicle-tauri/src/commands/cob/board.rs | 64 ++ desktop/crates/radicle-tauri/src/lib.rs | 6 + desktop/crates/radicle-types/Cargo.toml | 1 + .../radicle-types/bindings/cob/board/Board.ts | 11 + .../bindings/cob/board/BoardUpdate.ts | 9 + .../radicle-types/bindings/cob/board/Card.ts | 3 + .../bindings/cob/board/CardMove.ts | 8 + .../bindings/cob/board/Closes.ts | 3 + .../bindings/cob/board/Column.ts | 4 + .../bindings/cob/board/PatchLink.ts | 7 + .../bindings/cob/board/Placement.ts | 11 + desktop/crates/radicle-types/src/cobs.rs | 1 + .../crates/radicle-types/src/cobs/board.rs | 320 +++++++++ desktop/crates/radicle-types/src/error.rs | 20 + desktop/crates/radicle-types/src/lib.rs | 2 + desktop/crates/radicle-types/src/traits.rs | 1 + .../crates/radicle-types/src/traits/board.rs | 627 ++++++++++++++++++ desktop/crates/test-http-api/src/api.rs | 115 ++++ 20 files changed, 1215 insertions(+) create mode 100644 desktop/crates/radicle-tauri/src/commands/cob/board.rs create mode 100644 desktop/crates/radicle-types/bindings/cob/board/Board.ts create mode 100644 desktop/crates/radicle-types/bindings/cob/board/BoardUpdate.ts create mode 100644 desktop/crates/radicle-types/bindings/cob/board/Card.ts create mode 100644 desktop/crates/radicle-types/bindings/cob/board/CardMove.ts create mode 100644 desktop/crates/radicle-types/bindings/cob/board/Closes.ts create mode 100644 desktop/crates/radicle-types/bindings/cob/board/Column.ts create mode 100644 desktop/crates/radicle-types/bindings/cob/board/PatchLink.ts create mode 100644 desktop/crates/radicle-types/bindings/cob/board/Placement.ts create mode 100644 desktop/crates/radicle-types/src/cobs/board.rs create mode 100644 desktop/crates/radicle-types/src/traits/board.rs diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index 608a20b22..8b24ce47f 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -4828,6 +4828,7 @@ dependencies = [ "radicle-artifact", "radicle-artifact-client", "radicle-artifact-core", + "radicle-board", "radicle-job", "radicle-localtime", "radicle-surf", diff --git a/desktop/crates/radicle-tauri/src/commands/cob.rs b/desktop/crates/radicle-tauri/src/commands/cob.rs index 7b9722afc..42383b171 100644 --- a/desktop/crates/radicle-tauri/src/commands/cob.rs +++ b/desktop/crates/radicle-tauri/src/commands/cob.rs @@ -9,6 +9,7 @@ use tauri_plugin_dialog::DialogExt; use crate::AppState; +pub mod board; pub mod issue; pub mod job; pub mod patch; diff --git a/desktop/crates/radicle-tauri/src/commands/cob/board.rs b/desktop/crates/radicle-tauri/src/commands/cob/board.rs new file mode 100644 index 000000000..f77769252 --- /dev/null +++ b/desktop/crates/radicle-tauri/src/commands/cob/board.rs @@ -0,0 +1,64 @@ +use radicle::cob::ObjectId; +use radicle::identity::RepoId; +use radicle_types as types; +use radicle_types::cobs::board::{Board, BoardUpdate, Card, CardMove, Column, PatchLink}; +use radicle_types::error::Error; +use radicle_types::traits::board::Boards; + +use crate::AppState; + +#[tauri::command] +pub fn list_boards(ctx: tauri::State, rid: RepoId) -> Result, Error> { + ctx.list_boards(rid) +} + +#[tauri::command] +pub fn create_board( + ctx: tauri::State, + rid: RepoId, + name: String, + opts: types::cobs::CobOptions, +) -> Result { + ctx.create_board(rid, name, opts) +} + +// Writing signs refs and announces to the node, which would block the main +// thread, and with it the window, for as long as that takes. +#[tauri::command(async)] +pub fn update_board( + ctx: tauri::State, + rid: RepoId, + board: ObjectId, + moves: Vec, + removes: Vec, + opts: types::cobs::CobOptions, +) -> Result { + ctx.update_board(rid, board, moves, removes, opts) +} + +#[tauri::command] +pub fn board_links(ctx: tauri::State, rid: RepoId) -> Result, Error> { + ctx.board_links(rid) +} + +#[tauri::command] +pub fn rename_board( + ctx: tauri::State, + rid: RepoId, + board: ObjectId, + name: String, + opts: types::cobs::CobOptions, +) -> Result { + ctx.rename_board(rid, board, name, opts) +} + +#[tauri::command] +pub fn set_board_columns( + ctx: tauri::State, + rid: RepoId, + board: ObjectId, + columns: Vec, + opts: types::cobs::CobOptions, +) -> Result { + ctx.set_board_columns(rid, board, columns, opts) +} diff --git a/desktop/crates/radicle-tauri/src/lib.rs b/desktop/crates/radicle-tauri/src/lib.rs index 8b2198918..a1c057d2c 100644 --- a/desktop/crates/radicle-tauri/src/lib.rs +++ b/desktop/crates/radicle-tauri/src/lib.rs @@ -82,6 +82,12 @@ pub fn run() { auth::authenticate, auth::init, cob::get_embed, + cob::board::board_links, + cob::board::create_board, + cob::board::list_boards, + cob::board::rename_board, + cob::board::set_board_columns, + cob::board::update_board, cob::issue::activity_by_issue, cob::issue::comment_threads_by_issue_id, cob::issue::delete_issue, diff --git a/desktop/crates/radicle-types/Cargo.toml b/desktop/crates/radicle-types/Cargo.toml index 2bb3ef1c3..47cf5b7e5 100644 --- a/desktop/crates/radicle-types/Cargo.toml +++ b/desktop/crates/radicle-types/Cargo.toml @@ -18,6 +18,7 @@ radicle = { version = "0.25", features = ["tor", "i2p"] } radicle-artifact = { version = "0.23" } radicle-artifact-client = { version = "0.23" } radicle-artifact-core = { version = "0.23" } +radicle-board = { path = "../radicle-board" } radicle-job = { version = "0.6" } radicle-localtime = { version = "0.1.0", features = ["serde"] } radicle-surf = { version = "0.29.0", features = ["serde"] } diff --git a/desktop/crates/radicle-types/bindings/cob/board/Board.ts b/desktop/crates/radicle-types/bindings/cob/board/Board.ts new file mode 100644 index 000000000..459a199a7 --- /dev/null +++ b/desktop/crates/radicle-types/bindings/cob/board/Board.ts @@ -0,0 +1,11 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Column } from "./Column"; +import type { Placement } from "./Placement"; + +export type Board = { + id: string; + name: string; + createdAt: number; + columns: Array; + cards: Array; +}; diff --git a/desktop/crates/radicle-types/bindings/cob/board/BoardUpdate.ts b/desktop/crates/radicle-types/bindings/cob/board/BoardUpdate.ts new file mode 100644 index 000000000..5edfe9cb6 --- /dev/null +++ b/desktop/crates/radicle-types/bindings/cob/board/BoardUpdate.ts @@ -0,0 +1,9 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Board } from "./Board"; +import type { Card } from "./Card"; + +/** + * A board after a batch of changes, and the cards whose changes weren't + * made because we aren't allowed to make them. + */ +export type BoardUpdate = { board: Board; rejected: Array }; diff --git a/desktop/crates/radicle-types/bindings/cob/board/Card.ts b/desktop/crates/radicle-types/bindings/cob/board/Card.ts new file mode 100644 index 000000000..79e7bea57 --- /dev/null +++ b/desktop/crates/radicle-types/bindings/cob/board/Card.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type Card = { rid: string; typeName: string; oid: string }; diff --git a/desktop/crates/radicle-types/bindings/cob/board/CardMove.ts b/desktop/crates/radicle-types/bindings/cob/board/CardMove.ts new file mode 100644 index 000000000..da2a5b145 --- /dev/null +++ b/desktop/crates/radicle-types/bindings/cob/board/CardMove.ts @@ -0,0 +1,8 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Card } from "./Card"; + +/** + * A card placed in `column` at `position`, a key from + * `radicle_board::position`. + */ +export type CardMove = { card: Card; column: string; position: string }; diff --git a/desktop/crates/radicle-types/bindings/cob/board/Closes.ts b/desktop/crates/radicle-types/bindings/cob/board/Closes.ts new file mode 100644 index 000000000..11cb25508 --- /dev/null +++ b/desktop/crates/radicle-types/bindings/cob/board/Closes.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type Closes = "solved" | "other"; diff --git a/desktop/crates/radicle-types/bindings/cob/board/Column.ts b/desktop/crates/radicle-types/bindings/cob/board/Column.ts new file mode 100644 index 000000000..5e35d32e6 --- /dev/null +++ b/desktop/crates/radicle-types/bindings/cob/board/Column.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Closes } from "./Closes"; + +export type Column = { id: string; name: string; closes?: Closes }; diff --git a/desktop/crates/radicle-types/bindings/cob/board/PatchLink.ts b/desktop/crates/radicle-types/bindings/cob/board/PatchLink.ts new file mode 100644 index 000000000..e1d3089a1 --- /dev/null +++ b/desktop/crates/radicle-types/bindings/cob/board/PatchLink.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * The issues a patch links to with a keyword and a full reference in its + * title or description. + */ +export type PatchLink = { patch: string; issues: Array }; diff --git a/desktop/crates/radicle-types/bindings/cob/board/Placement.ts b/desktop/crates/radicle-types/bindings/cob/board/Placement.ts new file mode 100644 index 000000000..2629f6883 --- /dev/null +++ b/desktop/crates/radicle-types/bindings/cob/board/Placement.ts @@ -0,0 +1,11 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Author } from "../Author"; +import type { Card } from "./Card"; + +export type Placement = { + card: Card; + column: string; + position: string; + movedBy: Author; + movedAt: number; +}; diff --git a/desktop/crates/radicle-types/src/cobs.rs b/desktop/crates/radicle-types/src/cobs.rs index ae58dd6b6..9239eeb53 100644 --- a/desktop/crates/radicle-types/src/cobs.rs +++ b/desktop/crates/radicle-types/src/cobs.rs @@ -8,6 +8,7 @@ use radicle::cob; use radicle::identity; use radicle::node::{Alias, AliasStore}; +pub mod board; pub mod diff; pub mod issue; pub mod job; diff --git a/desktop/crates/radicle-types/src/cobs/board.rs b/desktop/crates/radicle-types/src/cobs/board.rs new file mode 100644 index 000000000..56c62a896 --- /dev/null +++ b/desktop/crates/radicle-types/src/cobs/board.rs @@ -0,0 +1,320 @@ +use radicle::cob::{ObjectId, TypeName}; +use radicle::identity::RepoId; +use radicle::node::AliasStore; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +use crate::cobs::Author; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +#[ts(export_to = "cob/board/")] +pub struct Card { + #[ts(as = "String")] + pub rid: RepoId, + #[ts(as = "String")] + pub type_name: TypeName, + #[ts(as = "String")] + pub oid: ObjectId, +} + +impl From for radicle_board::Card { + fn from(card: Card) -> Self { + Self { + rid: card.rid, + type_name: card.type_name, + oid: card.oid, + } + } +} + +impl From<&radicle_board::Card> for Card { + fn from(card: &radicle_board::Card) -> Self { + Self { + rid: card.rid, + type_name: card.type_name.clone(), + oid: card.oid, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +#[ts(export_to = "cob/board/")] +pub enum Closes { + Solved, + Other, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +#[ts(export_to = "cob/board/")] +pub struct Column { + pub id: String, + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub closes: Option, +} + +impl From for radicle_board::Column { + fn from(column: Column) -> Self { + Self { + id: column.id, + name: column.name, + closes: column.closes.map(|closes| match closes { + Closes::Solved => radicle_board::Closes::Solved, + Closes::Other => radicle_board::Closes::Other, + }), + } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +#[ts(export_to = "cob/board/")] +pub struct Placement { + pub card: Card, + pub column: String, + pub position: String, + pub moved_by: Author, + #[ts(type = "number")] + pub moved_at: radicle::cob::Timestamp, +} + +#[derive(Clone, Debug, PartialEq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +#[ts(export_to = "cob/board/")] +pub struct Board { + #[ts(as = "String")] + pub id: ObjectId, + pub name: String, + #[ts(type = "number")] + pub created_at: radicle::cob::Timestamp, + pub columns: Vec, + pub cards: Vec, +} + +impl Board { + pub fn new(id: ObjectId, board: &radicle_board::Board, aliases: &impl AliasStore) -> Self { + Self { + id, + name: board.name().to_string(), + created_at: board.created_at(), + columns: board + .columns() + .iter() + .map(|column| Column { + id: column.id.clone(), + name: column.name.clone(), + closes: column.closes.map(|closes| match closes { + radicle_board::Closes::Solved => Closes::Solved, + radicle_board::Closes::Other => Closes::Other, + }), + }) + .collect(), + cards: board + .cards() + .into_iter() + .map(|(card, placement)| Placement { + card: card.into(), + column: placement.column.clone(), + position: placement.position.clone(), + moved_by: Author::new(&placement.moved_by.into(), aliases), + moved_at: placement.moved_at, + }) + .collect(), + } + } +} + +/// A card placed in `column` at `position`, a key from +/// `radicle_board::position`. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +#[ts(export_to = "cob/board/")] +pub struct CardMove { + pub card: Card, + pub column: String, + pub position: String, +} + +/// A board after a batch of changes, and the cards whose changes weren't +/// made because we aren't allowed to make them. +#[derive(Clone, Debug, PartialEq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +#[ts(export_to = "cob/board/")] +pub struct BoardUpdate { + pub board: Board, + pub rejected: Vec, +} + +/// The issues a patch links to with a keyword and a full reference in its +/// title or description. +#[derive(Clone, Debug, PartialEq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +#[ts(export_to = "cob/board/")] +pub struct PatchLink { + #[ts(as = "String")] + pub patch: ObjectId, + #[ts(as = "Vec")] + pub issues: Vec, +} + +const LINK_KEYWORDS: [&str; 6] = ["fixes", "fix", "closes", "close", "resolves", "resolve"]; + +/// Issues in the repository `rid` that `text` links to: a keyword such as +/// `Fixes`, optionally followed by a colon, and then the issue's full +/// `rad:/cob/xyz.radicle.issue/` reference, bare or as the target of +/// a Markdown link. References to other repositories and bare ids don't link. +pub fn linked_issues(text: &str, rid: RepoId) -> Vec { + let mut found = Vec::new(); + let mut rest = text; + while let Some((word, after)) = next_word(rest) { + rest = after; + let keyword = word.strip_suffix(':').unwrap_or(word); + if !LINK_KEYWORDS + .iter() + .any(|k| keyword.eq_ignore_ascii_case(k)) + { + continue; + } + let after = after.trim_start(); + let reference = match after.strip_prefix('[') { + Some(link) => link + .split_once("](") + .and_then(|(_, target)| target.split_once(')')) + .map(|(target, _)| target), + None => after.split_whitespace().next(), + }; + if let Some(id) = reference.and_then(|r| issue_reference(r, rid)) + && !found.contains(&id) + { + found.push(id); + } + } + found +} + +fn next_word(text: &str) -> Option<(&str, &str)> { + let text = text.trim_start(); + if text.is_empty() { + return None; + } + let end = text.find(char::is_whitespace).unwrap_or(text.len()); + Some(text.split_at(end)) +} + +fn issue_reference(reference: &str, rid: RepoId) -> Option { + let reference = reference.trim_end_matches(['.', ',', ';', ':', ')', '!', '?']); + let (repo, rest) = reference.split_once("/cob/")?; + if repo.parse::().ok()? != rid { + return None; + } + let oid = rest.strip_prefix("xyz.radicle.issue/")?; + if oid.len() != 40 || !oid.bytes().all(|b| b.is_ascii_hexdigit()) { + return None; + } + oid.parse().ok() +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod test { + use std::str::FromStr; + + use radicle::cob::ObjectId; + use radicle::identity::RepoId; + + use super::linked_issues; + + const RID: &str = "rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5"; + const OTHER: &str = "rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5"; + const A: &str = "9c07f59c0a7e4b1f3d2e6a8b5c4d3e2f1a0b9c8d"; + const B: &str = "12e0648a7e4b1f3d2e6a8b5c4d3e2f1a0b9c8d7e"; + + fn id(hex: &str) -> ObjectId { + ObjectId::from_str(hex).unwrap() + } + + fn rid() -> RepoId { + RID.parse().unwrap() + } + + fn reference(rid: &str, oid: &str) -> String { + format!("{rid}/cob/xyz.radicle.issue/{oid}") + } + + #[test] + fn keywords_link_full_references() { + let text = format!( + "Fixes {}\nIt also closes: {}, and FIX {}.", + reference(RID, A), + reference(RID, B), + reference(RID, A) + ); + assert_eq!(linked_issues(&text, rid()), vec![id(A), id(B)]); + for keyword in ["Resolves", "resolve", "Close", "closes:"] { + let text = format!("{keyword} {}", reference(RID, A)); + assert_eq!(linked_issues(&text, rid()), vec![id(A)], "{keyword}"); + } + } + + #[test] + fn references_need_a_keyword_right_before_them() { + assert!(linked_issues(&format!("See {}", reference(RID, A)), rid()).is_empty()); + assert!( + linked_issues(&format!("Fixes the bug in {}", reference(RID, A)), rid()).is_empty() + ); + assert!(linked_issues(&format!("{} fixes", reference(RID, A)), rid()).is_empty()); + } + + #[test] + fn other_repositories_bare_ids_and_other_types_do_not_link() { + assert!(linked_issues(&format!("Fixes {}", reference(OTHER, A)), rid()).is_empty()); + assert!(linked_issues(&format!("Fixes {A}"), rid()).is_empty()); + assert!(linked_issues(&format!("Fixes {}", &A[..7]), rid()).is_empty()); + assert!(linked_issues(&format!("Fixes {RID}/cob/xyz.radicle.patch/{A}"), rid()).is_empty()); + assert!( + linked_issues( + &format!("Fixes {RID}/cob/xyz.radicle.issue/{}", &A[..39]), + rid() + ) + .is_empty() + ); + assert!(linked_issues("Fixes", rid()).is_empty()); + } + + #[test] + fn markdown_links_link_by_their_target() { + let text = format!( + "Closes: [Attachments]({}).\nFixes [the flaky upload test]({})", + reference(RID, A), + reference(RID, B) + ); + assert_eq!(linked_issues(&text, rid()), vec![id(A), id(B)]); + } + + #[test] + fn markdown_links_follow_the_same_rules() { + let other = format!("Fixes [Attachments]({})", reference(OTHER, A)); + assert!(linked_issues(&other, rid()).is_empty()); + let short = format!( + "Fixes [Attachments]({RID}/cob/xyz.radicle.issue/{})", + &A[..7] + ); + assert!(linked_issues(&short, rid()).is_empty()); + let unclosed = format!("Fixes [Attachments]({}", reference(RID, A)); + assert!(linked_issues(&unclosed, rid()).is_empty()); + let without_keyword = format!("See [Attachments]({})", reference(RID, A)); + assert!(linked_issues(&without_keyword, rid()).is_empty()); + } +} diff --git a/desktop/crates/radicle-types/src/error.rs b/desktop/crates/radicle-types/src/error.rs index f59168c4f..b86b03ece 100644 --- a/desktop/crates/radicle-types/src/error.rs +++ b/desktop/crates/radicle-types/src/error.rs @@ -225,6 +225,25 @@ pub enum Error { /// Artifact node client error. #[error(transparent)] ArtifactClient(#[from] radicle_artifact_client::ClientError), + /// Board error. + #[error("{0}")] + Board(&'static str), +} + +impl From for Error { + fn from(err: radicle_board::UpdateError) -> Self { + match err { + radicle_board::UpdateError::Store(e) => Error::CobStore(e), + radicle_board::UpdateError::Share(radicle_board::ShareError::Private(_)) => { + Error::Board( + "cards from private repositories can only go on their own repository's boards", + ) + } + radicle_board::UpdateError::Share(radicle_board::ShareError::Unavailable(_)) => { + Error::Board("the card's repository isn't available locally") + } + } + } } impl Error { @@ -258,6 +277,7 @@ impl Error { Error::ArtifactCore(radicle_artifact_core::Error::CidMismatch { .. }) => { "ArtifactError.CidMismatch" } + Error::Board(_) => "BoardError", _ => "UnknownError", } } diff --git a/desktop/crates/radicle-types/src/lib.rs b/desktop/crates/radicle-types/src/lib.rs index 8ca57dc7f..27fd48176 100644 --- a/desktop/crates/radicle-types/src/lib.rs +++ b/desktop/crates/radicle-types/src/lib.rs @@ -1,5 +1,6 @@ use traits::Profile; use traits::artifact_node::ArtifactNode; +use traits::board::Boards; use traits::cobs::Cobs; use traits::identity::Identity; use traits::inbox::Inbox; @@ -31,6 +32,7 @@ pub struct AppState { pub profile: radicle::Profile, } +impl Boards for AppState {} impl Repo for AppState {} impl Thread for AppState {} impl Cobs for AppState {} diff --git a/desktop/crates/radicle-types/src/traits.rs b/desktop/crates/radicle-types/src/traits.rs index abaeb2826..fa4d66ea5 100644 --- a/desktop/crates/radicle-types/src/traits.rs +++ b/desktop/crates/radicle-types/src/traits.rs @@ -11,6 +11,7 @@ use crate::error::Error; const ALIAS_SEARCH_LIMIT: usize = 50; pub mod artifact_node; +pub mod board; pub mod cobs; pub mod identity; pub mod inbox; diff --git a/desktop/crates/radicle-types/src/traits/board.rs b/desktop/crates/radicle-types/src/traits/board.rs new file mode 100644 index 000000000..043d2241d --- /dev/null +++ b/desktop/crates/radicle-types/src/traits/board.rs @@ -0,0 +1,627 @@ +use std::collections::HashSet; + +use radicle::cob::store::access::{ReadOnly, WriteAs}; +use radicle::cob::{self, ObjectId, Op}; +use radicle::git::Oid; +use radicle::identity::{Did, RepoId}; +use radicle::issue::cache::Issues as _; +use radicle::node::{Handle, Node}; +use radicle::patch::cache::Patches as _; +use radicle::storage::{ReadRepository, ReadStorage}; +use radicle_board::{ + Boards as BoardStore, MAX_COLUMN_ID_LEN, MAX_NAME_LEN, MAX_POSITION_LEN, position, +}; + +use crate::cobs; +use crate::cobs::board::{Board, BoardUpdate, Card, CardMove, Column, PatchLink, linked_issues}; +use crate::error::Error; +use crate::traits::Profile; + +pub trait Boards: Profile { + fn list_boards(&self, rid: RepoId) -> Result, Error> { + let profile = self.profile(); + let repo = profile.storage.repository(rid)?; + let aliases = profile.aliases(); + let boards = BoardStore::open(&repo, ReadOnly)?; + + let mut boards = boards + .list()? + .into_iter() + .map(|(id, board)| Board::new(id, &board, &aliases)) + .collect::>(); + boards.sort_by(|a, b| { + a.created_at + .cmp(&b.created_at) + .then_with(|| a.id.cmp(&b.id)) + }); + + Ok(boards) + } + + fn create_board( + &self, + rid: RepoId, + name: String, + opts: cobs::CobOptions, + ) -> Result { + let name = valid_name(&name)?; + let profile = self.profile(); + let repo = profile.storage.repository(rid)?; + if !repo + .identity_doc()? + .is_delegate(&Did::from(profile.public_key)) + { + return Err(Error::Board("only delegates can create a board")); + } + let signer = profile.signer()?; + let aliases = profile.aliases(); + let mut boards = BoardStore::open(&repo, WriteAs::new(&signer))?; + let (id, board) = boards.create(name)?; + announce(&profile, rid, &opts); + + Ok(Board::new(id, &board, &aliases)) + } + + /// Apply a batch of card moves and removals as one operation, so a burst + /// of changes costs a single update of the signed refs. Changes we aren't + /// allowed to make are left out before writing and returned as rejected. + fn update_board( + &self, + rid: RepoId, + board: ObjectId, + moves: Vec, + removes: Vec, + opts: cobs::CobOptions, + ) -> Result { + let profile = self.profile(); + let repo = profile.storage.repository(rid)?; + let signer = profile.signer()?; + let aliases = profile.aliases(); + let me = profile.public_key; + + let mut boards = BoardStore::open(&repo, WriteAs::new(&signer))?; + let current = boards + .get(&board)? + .ok_or(Error::Board("the board doesn't exist"))?; + let mut actions = Vec::new(); + let mut expected = Vec::new(); + let mut rejected = Vec::new(); + + for CardMove { + card, + column, + position, + } in moves + { + let target: radicle_board::Card = card.clone().into(); + if !position::is_valid(&position) + || position.len() > MAX_POSITION_LEN + || column.is_empty() + || column.len() > MAX_COLUMN_ID_LEN + || boards.shareable(&profile.storage, &target).is_err() + { + rejected.push(card); + continue; + } + let proof = assignment(&repo, &target, &Did::from(me)); + let action = + boards.move_action(target.clone(), column.clone(), position.clone(), proof); + if !boards.authorized(¤t, &action, &me)? { + rejected.push(card); + continue; + } + actions.push(action); + expected.push((card, target, Some((column, position)))); + } + for card in removes { + let target: radicle_board::Card = card.clone().into(); + let action = radicle_board::Action::Remove { + card: target.clone(), + }; + if !boards.authorized(¤t, &action, &me)? { + rejected.push(card); + continue; + } + actions.push(action); + expected.push((card, target, None)); + } + + let updated = if actions.is_empty() { + current + } else { + let updated = boards.update(&profile.storage, &board, actions)?; + announce(&profile, rid, &opts); + updated + }; + for (card, target, placement) in expected { + let applied = match placement { + Some((column, position)) => updated + .placement(&target) + .is_some_and(|p| p.column == column && p.position == position), + None => updated.placement(&target).is_none(), + }; + if !applied { + rejected.push(card); + } + } + + Ok(BoardUpdate { + board: Board::new(board, &updated, &aliases), + rejected, + }) + } + + /// The issues each patch links to; see [`linked_issues`]. Links to + /// issues that don't exist are left out. + fn board_links(&self, rid: RepoId) -> Result, Error> { + let profile = self.profile(); + let repo = profile.storage.repository(rid)?; + let issues = profile + .issues(&repo)? + .list()? + .filter_map(|issue| issue.ok().map(|(id, _)| id)) + .collect::>(); + + Ok(profile + .patches(&repo)? + .list()? + .filter_map(Result::ok) + .filter_map(|(id, patch)| { + let text = format!("{}\n{}", patch.title(), patch.description()); + let issues: Vec<_> = linked_issues(&text, rid) + .into_iter() + .filter(|issue| issues.contains(issue)) + .collect(); + (!issues.is_empty()).then_some(PatchLink { patch: id, issues }) + }) + .collect()) + } + + fn rename_board( + &self, + rid: RepoId, + board: ObjectId, + name: String, + opts: cobs::CobOptions, + ) -> Result { + let name = valid_name(&name)?; + let profile = self.profile(); + let repo = profile.storage.repository(rid)?; + let signer = profile.signer()?; + let aliases = profile.aliases(); + let mut boards = BoardStore::open(&repo, WriteAs::new(&signer))?; + let current = boards + .get(&board)? + .ok_or(Error::Board("the board doesn't exist"))?; + let action = radicle_board::Action::Edit { + name: name.to_string(), + }; + if !boards.authorized(¤t, &action, &profile.public_key)? { + return Err(Error::Board("only delegates can rename a board")); + } + let updated = boards.rename(&board, name)?; + announce(&profile, rid, &opts); + + Ok(Board::new(board, &updated, &aliases)) + } + + fn set_board_columns( + &self, + rid: RepoId, + board: ObjectId, + columns: Vec, + opts: cobs::CobOptions, + ) -> Result { + let columns: Vec = columns.into_iter().map(Into::into).collect(); + if !radicle_board::valid_columns(&columns) { + return Err(Error::Board( + "a board has 1 to 50 columns, each with a distinct id and a name of up to 100 bytes", + )); + } + let profile = self.profile(); + let repo = profile.storage.repository(rid)?; + let signer = profile.signer()?; + let aliases = profile.aliases(); + let mut boards = BoardStore::open(&repo, WriteAs::new(&signer))?; + let current = boards + .get(&board)? + .ok_or(Error::Board("the board doesn't exist"))?; + let action = radicle_board::Action::Columns { + columns: columns.clone(), + }; + if !boards.authorized(¤t, &action, &profile.public_key)? { + return Err(Error::Board("only delegates can change the columns")); + } + let updated = boards.set_columns(&board, columns.clone())?; + if updated.columns() != columns.as_slice() { + return Err(Error::Board("those columns weren't accepted")); + } + announce(&profile, rid, &opts); + + Ok(Board::new(board, &updated, &aliases)) + } +} + +fn valid_name(name: &str) -> Result<&str, Error> { + let name = name.trim(); + if name.is_empty() { + return Err(Error::Board("a board needs a name")); + } + if name.len() > MAX_NAME_LEN { + return Err(Error::Board("the board name is too long")); + } + Ok(name) +} + +/// The latest operation in which a delegate assigned `me` to the card's issue, +/// for when neither delegation nor authorship lets us move the card. +fn assignment( + repo: &R, + card: &radicle_board::Card, + me: &Did, +) -> Option { + if card.rid != repo.id() || card.type_name != *cob::issue::TYPENAME { + return None; + } + if repo.identity_doc().ok()?.is_delegate(me) { + return None; + } + let ops = cob::store::ops(&card.oid, &cob::issue::TYPENAME, repo).ok()?; + if Did::from(ops.first().author) == *me { + return None; + } + + ops.into_iter() + .filter(|op: &Op>| { + op.identity_doc(repo) + .ok() + .flatten() + .is_some_and(|doc| doc.is_delegate(&Did::from(op.author))) + }) + .filter(|op| { + op.actions.iter().any(|bytes| { + matches!( + serde_json::from_slice::(bytes), + Ok(cob::issue::Action::Assign { assignees }) if assignees.contains(me) + ) + }) + }) + .map(|op| op.id) + .last() +} + +fn announce(profile: &radicle::Profile, rid: RepoId, opts: &cobs::CobOptions) { + let mut node = Node::new(profile.home().socket_from_env()); + if opts.announce() + && let Err(e) = node.announce_refs_for(rid, [profile.public_key]) + { + log::error!("Not able to announce changes: {}", e) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod test { + use radicle::cob::cache::NoCache; + use radicle::cob::issue::Issues; + use radicle::cob::store::access::WriteAs; + use radicle::cob::{ObjectId, Title}; + use radicle::crypto::{Seed, Signer as _, SigningKey}; + use radicle::identity::{Did, RepoId, Visibility}; + use radicle::node::config::Network; + use radicle::storage::{ReadRepository, ReadStorage}; + use radicle::test::fixtures; + + use super::Boards; + use crate::cobs::CobOptions; + use crate::cobs::board::{Card, CardMove, Column}; + use crate::{AppState, test}; + + const SEED: [u8; 32] = [0xff; 32]; + const OTHER_SEED: [u8; 32] = [0xee; 32]; + + fn quiet() -> CobOptions { + serde_json::from_value(serde_json::json!({ "announce": false })).unwrap() + } + + fn setup(tmp: &std::path::Path, network: Network) -> (AppState, RepoId) { + setup_as(tmp, network, SEED) + } + + /// A profile for `SEED` with a project whose delegate is `delegate`. + fn setup_as(tmp: &std::path::Path, network: Network, delegate: [u8; 32]) -> (AppState, RepoId) { + let mut profile = test::profile(&tmp.join("home"), SEED); + profile.config.node.network = network; + let signer = SigningKey::from_seed(Seed::new(delegate)); + let (rid, _, _, _) = + fixtures::project(tmp.join("project"), &profile.storage, &signer).unwrap(); + (AppState { profile }, rid) + } + + /// A private repository with `SEED` as its delegate, next to the project. + fn private_project(tmp: &std::path::Path, state: &AppState) -> RepoId { + let signer = SigningKey::from_seed(Seed::new(SEED)); + let (working, _) = fixtures::repository(tmp.join("private")); + let (rid, _, _) = radicle::rad::init( + &working, + "secret".try_into().unwrap(), + "A private repository", + "master".try_into().unwrap(), + Visibility::private([]), + &signer, + &state.profile.storage, + ) + .unwrap(); + rid + } + + fn open_issue(state: &AppState, rid: RepoId, title: &str) -> Card { + open_issue_as(state, rid, title, SEED) + } + + fn open_issue_as(state: &AppState, rid: RepoId, title: &str, seed: [u8; 32]) -> Card { + let repo = state.profile.storage.repository(rid).unwrap(); + let signer = SigningKey::from_seed(Seed::new(seed)); + let mut issues = Issues::open(&repo, WriteAs::new(&signer)).unwrap(); + let mut cache = NoCache; + let issue = issues + .create(Title::new(title).unwrap(), "", &[], &[], [], &mut cache) + .unwrap(); + let oid = *issue.id(); + Card { + rid, + type_name: radicle::cob::issue::TYPENAME.clone(), + oid, + } + } + + fn moved(card: &Card, column: &str, position: &str) -> CardMove { + CardMove { + card: card.clone(), + column: column.into(), + position: position.into(), + } + } + + fn ops(state: &AppState, rid: RepoId, board: ObjectId) -> usize { + let repo = state.profile.storage.repository(rid).unwrap(); + radicle::cob::store::ops(&board, &radicle_board::TYPENAME, &repo) + .unwrap() + .len() + } + + fn sigrefs(state: &AppState, rid: RepoId) -> radicle::git::raw::Oid { + let repo = state.profile.storage.repository(rid).unwrap(); + let signer = SigningKey::from_seed(Seed::new(SEED)); + repo.backend + .refname_to_id(&format!( + "refs/namespaces/{}/refs/rad/sigrefs", + signer.public_key() + )) + .unwrap() + } + + #[test] + fn update_board_writes_a_batch_as_one_operation() { + let tmp = tempfile::tempdir().unwrap(); + let (state, rid) = setup(tmp.path(), Network::Test); + let a = open_issue(&state, rid, "First"); + let b = open_issue(&state, rid, "Second"); + let board = state.create_board(rid, "Roadmap".into(), quiet()).unwrap(); + let before = sigrefs(&state, rid); + + let update = state + .update_board( + rid, + board.id, + vec![moved(&a, "todo", "V"), moved(&b, "done", "V")], + vec![], + quiet(), + ) + .unwrap(); + + assert!(update.rejected.is_empty()); + assert_eq!(update.board.cards.len(), 2); + assert_eq!(ops(&state, rid, board.id), 2); + assert_ne!(sigrefs(&state, rid), before); + + let update = state + .update_board(rid, board.id, vec![], vec![a.clone()], quiet()) + .unwrap(); + assert!(update.rejected.is_empty()); + assert_eq!(update.board.cards.len(), 1); + assert_eq!(update.board.cards[0].card, b); + } + + #[test] + fn update_board_rejects_changes_it_cannot_make() { + let tmp = tempfile::tempdir().unwrap(); + let (state, rid) = setup(tmp.path(), Network::Test); + let a = open_issue(&state, rid, "First"); + let board = state.create_board(rid, "Roadmap".into(), quiet()).unwrap(); + let unknown_repo = Card { + rid: "rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5".parse().unwrap(), + ..a.clone() + }; + let before = sigrefs(&state, rid); + + let update = state + .update_board( + rid, + board.id, + vec![ + moved(&a, "", "V"), + moved(&a, "todo", "V0"), + moved(&unknown_repo, "todo", "V"), + ], + vec![], + quiet(), + ) + .unwrap(); + + assert_eq!(update.rejected, vec![a.clone(), a, unknown_repo]); + assert!(update.board.cards.is_empty()); + assert_eq!(ops(&state, rid, board.id), 1); + assert_eq!(sigrefs(&state, rid), before); + } + + #[test] + fn a_non_delegate_rename_or_column_change_writes_nothing() { + let tmp = tempfile::tempdir().unwrap(); + let (state, rid) = setup_as(tmp.path(), Network::Test, OTHER_SEED); + let delegate = SigningKey::from_seed(Seed::new(OTHER_SEED)); + let repo = state.profile.storage.repository(rid).unwrap(); + let (board, _) = radicle_board::Boards::open(&repo, WriteAs::new(&delegate)) + .unwrap() + .create("Roadmap") + .unwrap(); + + let err = state + .rename_board(rid, board, "Mine".into(), quiet()) + .unwrap_err(); + assert_eq!(err.code(), "BoardError"); + let err = state + .set_board_columns( + rid, + board, + vec![Column { + id: "mine".into(), + name: "Mine".into(), + closes: None, + }], + quiet(), + ) + .unwrap_err(); + assert_eq!(err.code(), "BoardError"); + assert_eq!(ops(&state, rid, board), 1); + } + + #[test] + fn duplicate_column_ids_write_nothing() { + let tmp = tempfile::tempdir().unwrap(); + let (state, rid) = setup(tmp.path(), Network::Test); + let board = state.create_board(rid, "Roadmap".into(), quiet()).unwrap(); + let before = sigrefs(&state, rid); + let column = |name: &str| Column { + id: "twice".into(), + name: name.into(), + closes: None, + }; + + let err = state + .set_board_columns(rid, board.id, vec![column("One"), column("Two")], quiet()) + .unwrap_err(); + + assert_eq!(err.code(), "BoardError"); + assert_eq!(ops(&state, rid, board.id), 1); + assert_eq!(sigrefs(&state, rid), before); + } + + #[test] + fn update_board_links_the_latest_assignment_as_proof() { + let tmp = tempfile::tempdir().unwrap(); + let (state, rid) = setup_as(tmp.path(), Network::Test, OTHER_SEED); + let delegate = SigningKey::from_seed(Seed::new(OTHER_SEED)); + let me = Did::from(state.profile.public_key); + let card = open_issue_as(&state, rid, "Delegate's issue", OTHER_SEED); + let repo = state.profile.storage.repository(rid).unwrap(); + let mut issues = Issues::open(&repo, WriteAs::new(&delegate)).unwrap(); + let mut cache = NoCache; + let mut issue = issues.get_mut(&card.oid, &mut cache).unwrap(); + let first = issue.assign([me]).unwrap(); + issue.assign([]).unwrap(); + let latest = issue.assign([me]).unwrap(); + let (board, _) = radicle_board::Boards::open(&repo, WriteAs::new(&delegate)) + .unwrap() + .create("Roadmap") + .unwrap(); + + let update = state + .update_board(rid, board, vec![moved(&card, "todo", "V")], vec![], quiet()) + .unwrap(); + + assert!(update.rejected.is_empty()); + assert_eq!(update.board.cards[0].column, "todo"); + let ops = radicle::cob::store::ops(&board, &radicle_board::TYPENAME, &repo).unwrap(); + let related = &ops.last().related; + assert!(related.contains(&latest)); + assert!(!related.contains(&first)); + } + + #[test] + fn a_private_repositorys_card_stays_off_other_boards() { + let tmp = tempfile::tempdir().unwrap(); + let (state, rid) = setup(tmp.path(), Network::Test); + let private = private_project(tmp.path(), &state); + let secret = open_issue(&state, private, "Secret"); + let board = state.create_board(rid, "Roadmap".into(), quiet()).unwrap(); + let before = sigrefs(&state, rid); + + let update = state + .update_board( + rid, + board.id, + vec![moved(&secret, "todo", "V")], + vec![], + quiet(), + ) + .unwrap(); + + assert_eq!(update.rejected, vec![secret.clone()]); + assert!(update.board.cards.is_empty()); + assert_eq!(ops(&state, rid, board.id), 1); + assert_eq!(sigrefs(&state, rid), before); + + // On its own repository's board it's an ordinary card. + let own = state + .create_board(private, "Private".into(), quiet()) + .unwrap(); + let update = state + .update_board( + private, + own.id, + vec![moved(&secret, "todo", "V")], + vec![], + quiet(), + ) + .unwrap(); + assert!(update.rejected.is_empty()); + } + + #[test] + fn board_links_follow_keywords_and_full_references() { + let tmp = tempfile::tempdir().unwrap(); + let (state, rid) = setup(tmp.path(), Network::Test); + let repo = state.profile.storage.repository(rid).unwrap(); + let signer = SigningKey::from_seed(Seed::new(SEED)); + let mut issues = state.profile.issues_mut(&repo, &signer).unwrap(); + let issue = *issues + .create(Title::new("Linked").unwrap(), "", &[], &[], []) + .unwrap() + .id(); + let other = *issues + .create(Title::new("Unlinked").unwrap(), "", &[], &[], []) + .unwrap() + .id(); + let (_, head) = repo.head().unwrap(); + let mut patches = state.profile.patches_mut(&repo, &signer).unwrap(); + let patch = *patches + .create( + Title::new("Shout").unwrap(), + format!( + "Fixes {rid}/cob/xyz.radicle.issue/{issue} but not {other} or rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5/cob/xyz.radicle.issue/{other}" + ), + radicle::cob::patch::MergeTarget::Delegates, + head, + head, + &[], + ) + .unwrap() + .id(); + + let links = state.board_links(rid).unwrap(); + assert_eq!(links.len(), 1); + assert_eq!(links[0].patch, patch); + assert_eq!(links[0].issues, vec![issue]); + } +} diff --git a/desktop/crates/test-http-api/src/api.rs b/desktop/crates/test-http-api/src/api.rs index 3b40fd105..82868d0e1 100644 --- a/desktop/crates/test-http-api/src/api.rs +++ b/desktop/crates/test-http-api/src/api.rs @@ -38,6 +38,7 @@ use radicle_types::error::Error; use radicle_types::outbound::sqlite::Sqlite; use radicle_types::traits::Profile; use radicle_types::traits::artifact_node::ArtifactNode; +use radicle_types::traits::board::Boards; use radicle_types::traits::cobs::Cobs; use radicle_types::traits::identity::Identity; use radicle_types::traits::inbox::Inbox; @@ -57,6 +58,7 @@ pub struct Context { inbox: Arc>, } +impl Boards for Context {} impl Repo for Context {} impl Cobs for Context {} impl Identity for Context {} @@ -171,6 +173,10 @@ pub fn router(shared: Shared) -> Router { .route("/seed_artifact", post(seed_artifact_handler)) .route("/unseed_artifact", post(unseed_artifact_handler)) .route("/download_artifact", post(download_artifact_handler)) + .route("/create_board", post(create_board_handler)) + .route("/update_board", post(update_board_handler)) + .route("/rename_board", post(rename_board_handler)) + .route("/set_board_columns", post(set_board_columns_handler)) .route_layer(middleware::from_fn_with_state( Arc::new(Mutex::new(())), serialize_writes, @@ -243,6 +249,8 @@ pub fn router(shared: Shared) -> Router { .route("/artifact_node_status", post(artifact_node_status_handler)) .route("/seeded_artifacts", post(seeded_artifacts_handler)) .route("/list_jobs", post(jobs_handler)) + .route("/list_boards", post(list_boards_handler)) + .route("/board_links", post(board_links_handler)) .route("/list_notifications", post(list_notifications_handler)) .route("/notification_count", post(notification_count_handler)) .merge(writes) @@ -1537,3 +1545,110 @@ async fn jobs_handler( Ok::<_, Error>(Json(jobs)) } + +#[derive(Serialize, Deserialize)] +struct ListBoardsBody { + pub rid: identity::RepoId, +} + +async fn list_boards_handler( + Ctx(ctx): Ctx, + Json(ListBoardsBody { rid }): Json, +) -> impl IntoResponse { + let boards = ctx.list_boards(rid)?; + + Ok::<_, Error>(Json(boards)) +} + +#[derive(Serialize, Deserialize)] +struct CreateBoardBody { + pub rid: identity::RepoId, + pub name: String, + pub opts: CobOptions, +} + +async fn create_board_handler( + Ctx(ctx): Ctx, + Json(CreateBoardBody { rid, name, opts }): Json, +) -> impl IntoResponse { + let board = ctx.create_board(rid, name, opts)?; + + Ok::<_, Error>(Json(board)) +} + +#[derive(Serialize, Deserialize)] +struct UpdateBoardBody { + pub rid: identity::RepoId, + pub board: radicle::cob::ObjectId, + pub moves: Vec, + pub removes: Vec, + pub opts: CobOptions, +} + +async fn update_board_handler( + Ctx(ctx): Ctx, + Json(UpdateBoardBody { + rid, + board, + moves, + removes, + opts, + }): Json, +) -> impl IntoResponse { + let update = ctx.update_board(rid, board, moves, removes, opts)?; + + Ok::<_, Error>(Json(update)) +} + +async fn board_links_handler( + Ctx(ctx): Ctx, + Json(ListBoardsBody { rid }): Json, +) -> impl IntoResponse { + let links = ctx.board_links(rid)?; + + Ok::<_, Error>(Json(links)) +} + +#[derive(Serialize, Deserialize)] +struct RenameBoardBody { + pub rid: identity::RepoId, + pub board: radicle::cob::ObjectId, + pub name: String, + pub opts: CobOptions, +} + +async fn rename_board_handler( + Ctx(ctx): Ctx, + Json(RenameBoardBody { + rid, + board, + name, + opts, + }): Json, +) -> impl IntoResponse { + let board = ctx.rename_board(rid, board, name, opts)?; + + Ok::<_, Error>(Json(board)) +} + +#[derive(Serialize, Deserialize)] +struct SetBoardColumnsBody { + pub rid: identity::RepoId, + pub board: radicle::cob::ObjectId, + pub columns: Vec, + pub opts: CobOptions, +} + +async fn set_board_columns_handler( + Ctx(ctx): Ctx, + Json(SetBoardColumnsBody { + rid, + board, + columns, + opts, + }): Json, +) -> impl IntoResponse { + let board = ctx.set_board_columns(rid, board, columns, opts)?; + + Ok::<_, Error>(Json(board)) +}