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)) +}