From 3f32a750b83307c2864d7e529514c6216fa9ee07 Mon Sep 17 00:00:00 2001 From: Brandon Oxendine Date: Thu, 5 Nov 2026 16:42:46 +0100 Subject: [PATCH] desktop: Add the board COB MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A board arranges a repository's issues and patches in columns and stores only where each card sits, as `works.monadic.board` in its own crate so other clients can reuse it. Moves follow the issue and patch access rules, ops that fail them are ignored rather than rejected, and positions are fractional keys so concurrent moves converge. ADR 0005 records the decisions. Co-authored-by: Rūdolfs Ošiņš --- desktop/AGENTS.md | 4 + desktop/Cargo.lock | 11 + desktop/crates/radicle-board/Cargo.toml | 16 + desktop/crates/radicle-board/src/lib.rs | 724 +++++++++ desktop/crates/radicle-board/src/position.rs | 163 ++ desktop/crates/radicle-board/src/test.rs | 1339 +++++++++++++++++ .../tests/fixtures/positions.json | 223 +++ desktop/docs/adr/0005-boards.md | 126 ++ 8 files changed, 2606 insertions(+) create mode 100644 desktop/crates/radicle-board/Cargo.toml create mode 100644 desktop/crates/radicle-board/src/lib.rs create mode 100644 desktop/crates/radicle-board/src/position.rs create mode 100644 desktop/crates/radicle-board/src/test.rs create mode 100644 desktop/crates/radicle-board/tests/fixtures/positions.json create mode 100644 desktop/docs/adr/0005-boards.md diff --git a/desktop/AGENTS.md b/desktop/AGENTS.md index fd4fc7f98..efcd8d3e0 100644 --- a/desktop/AGENTS.md +++ b/desktop/AGENTS.md @@ -146,6 +146,10 @@ Paginated list views (issues, patches, notifications) go through a domain `Service` backed by the SQLite adapter instead of the ports; new lists over large data sets should do the same. +## Boards + +Read `docs/adr/0005-boards.md` before working on boards. + ## Code conventions ### Svelte components diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index 23ee8acc2..608a20b22 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -4627,6 +4627,17 @@ dependencies = [ "walkdir", ] +[[package]] +name = "radicle-board" +version = "0.1.0" +dependencies = [ + "log", + "radicle", + "serde", + "serde_json", + "thiserror 2.0.19", +] + [[package]] name = "radicle-cob" version = "0.21.1" diff --git a/desktop/crates/radicle-board/Cargo.toml b/desktop/crates/radicle-board/Cargo.toml new file mode 100644 index 000000000..b27450b59 --- /dev/null +++ b/desktop/crates/radicle-board/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "radicle-board" +description = "Radicle board collaborative object" +version = "0.1.0" +edition.workspace = true +license = "MIT OR Apache-2.0" + +[dependencies] +log = { version = "0.4" } +radicle = { version = "0.25" } +serde = { version = "1.0", features = ["derive"] } +serde_json = { version = "1.0" } +thiserror = { version = "2.0.12" } + +[dev-dependencies] +radicle = { version = "0.25", features = ["test"] } diff --git a/desktop/crates/radicle-board/src/lib.rs b/desktop/crates/radicle-board/src/lib.rs new file mode 100644 index 000000000..96768710e --- /dev/null +++ b/desktop/crates/radicle-board/src/lib.rs @@ -0,0 +1,724 @@ +//! A board collaborative object: a repository's issues and patches arranged +//! in columns. It stores only placement; titles, state and labels come from +//! the cards' own objects. +//! +//! Delegates may change anything. A card may also be moved by the author of +//! its issue or patch, and by an assignee who names the issue operation that +//! assigned them as proof. Everything a check reads is linked from the board +//! operation, so it replicates with it and every peer reaches the same +//! verdict. Actions that fail a check are ignored rather than rejected, so +//! nothing a peer writes drops later operations. Closing columns follow the +//! issue's own rules: only a delegate or the author moves an issue into or +//! out of one, and patches never enter one. + +use std::collections::BTreeMap; +use std::ops::Deref; +use std::str::FromStr; +use std::sync::LazyLock; + +use radicle::cob::store::{self, CobAction, CobWithType, access}; +use radicle::cob::{self, ActorId, Evaluate, ObjectId, Op, Timestamp, TypeName}; +use radicle::crypto; +use radicle::git::Oid; +use radicle::identity::{Did, DocAt, RepoId}; +use radicle::node::NodeId; +use radicle::storage::{ + ReadRepository, ReadStorage, RepositoryError, SignRepository, WriteRepository, +}; +use serde::{Deserialize, Serialize}; + +pub mod position; + +pub static TYPENAME: LazyLock = + LazyLock::new(|| FromStr::from_str("works.monadic.board").expect("type name is valid")); + +/// The newest format version this crate reads. Heartwood writes every +/// operation as version 1 and gives writers no way to choose, so raising this +/// needs heartwood to let us set the manifest version first. +pub const VERSION: usize = 1; + +/// The evaluator's limits, in UTF-8 bytes. +pub const MAX_NAME_LEN: usize = 100; +pub const MAX_COLUMNS: usize = 50; +pub const MAX_COLUMN_ID_LEN: usize = 64; +pub const MAX_POSITION_LEN: usize = 64; + +pub fn supported(version: cob::Version) -> bool { + usize::from(version) <= VERSION +} + +pub type BoardId = ObjectId; + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub struct Card { + pub rid: RepoId, + #[serde(rename = "type")] + pub type_name: TypeName, + pub oid: ObjectId, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Closes { + Solved, + Other, +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Column { + pub id: String, + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub closes: Option, +} + +impl Column { + pub fn new(id: &str, name: &str, closes: Option) -> Self { + Self { + id: id.to_string(), + name: name.to_string(), + closes, + } + } +} + +pub fn default_columns() -> Vec { + vec![ + Column::new("backlog", "Backlog", None), + Column::new("todo", "Todo", None), + Column::new("in-progress", "In Progress", None), + Column::new("in-review", "In Review", None), + Column::new("done", "Done", Some(Closes::Solved)), + Column::new("canceled", "Canceled", Some(Closes::Other)), + ] +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Placement { + pub column: String, + pub position: String, + pub moved_by: ActorId, + pub moved_at: Timestamp, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum Action { + /// Rename the board. The root operation must start with this action. + #[serde(rename = "edit")] + Edit { name: String }, + #[serde(rename = "columns")] + Columns { columns: Vec }, + #[serde(rename = "move")] + Move { + card: Card, + column: String, + position: String, + /// The issue operation that assigned the mover to the card's issue. + #[serde(default, skip_serializing_if = "Option::is_none")] + proof: Option, + /// Whether the card's object lives in the board's repository, so + /// that the operation links it. Set when the operation is created. + #[serde(skip)] + local: bool, + }, + #[serde(rename = "remove")] + Remove { card: Card }, +} + +impl CobAction for Action { + fn parents(&self) -> Vec { + match self { + Action::Move { + card, proof, local, .. + } => { + let mut parents = Vec::new(); + if *local { + parents.push(*card.oid); + } + parents.extend(proof); + parents + } + _ => Vec::new(), + } + } +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("the first action of a board must be `edit`")] + Init, + #[error("a board can only be created by a delegate")] + NotDelegate, + #[error("operation has no identity document")] + MissingIdentity, + #[error(transparent)] + Doc(#[from] radicle::identity::DocError), + #[error(transparent)] + Op(#[from] cob::op::OpEncodingError), + #[error(transparent)] + Store(#[from] store::Error), + #[error("the board is in format version {0}, newer than this version reads")] + Version(usize), + #[error("invalid board: {0}")] + Invalid(&'static str), +} + +/// Why a card can't go on a board. +#[derive(Debug, thiserror::Error)] +pub enum ShareError { + #[error("the card's repository {0} isn't available locally")] + Unavailable(RepoId), + #[error("cards from private repositories can only go on their own repository's boards")] + Private(RepoId), +} + +#[derive(Debug, thiserror::Error)] +pub enum UpdateError { + #[error(transparent)] + Share(#[from] ShareError), + #[error(transparent)] + Store(#[from] store::Error), +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Board { + name: String, + created_at: Timestamp, + columns: Vec, + cards: BTreeMap, +} + +impl Board { + pub fn name(&self) -> &str { + &self.name + } + + pub fn created_at(&self) -> Timestamp { + self.created_at + } + + pub fn columns(&self) -> &[Column] { + &self.columns + } + + pub fn placement(&self, card: &Card) -> Option<&Placement> { + self.cards.get(card) + } + + /// Cards in board order. A card placed in a column the board doesn't + /// have is left out, so it shows as unplaced until that column exists. + /// Ties in position are broken by card, so concurrent moves to the same + /// position settle the same way everywhere. + pub fn cards(&self) -> Vec<(&Card, &Placement)> { + let mut cards: Vec<_> = self + .cards + .iter() + .filter_map(|(card, placement)| { + let column = self.columns.iter().position(|c| c.id == placement.column)?; + Some((column, card, placement)) + }) + .collect(); + cards.sort_by(|(a_col, a_card, a), (b_col, b_card, b)| { + a_col + .cmp(b_col) + .then_with(|| a.position.cmp(&b.position)) + .then_with(|| a_card.cmp(b_card)) + }); + cards + .into_iter() + .map(|(_, card, placement)| (card, placement)) + .collect() + } + + /// What `column` does to issues. A column the board doesn't have closes + /// nothing. + fn closes(&self, column: &str) -> Option { + self.columns + .iter() + .find(|c| c.id == column) + .and_then(|c| c.closes) + } + + fn apply_action(&mut self, action: Action, author: ActorId, timestamp: Timestamp) { + match action { + Action::Edit { name } => { + if !name.is_empty() && name.len() <= MAX_NAME_LEN { + self.name = name; + } + } + Action::Columns { columns } => { + if valid_columns(&columns) { + self.columns = columns; + } + } + Action::Move { + card, + column, + position, + .. + } => { + // The column doesn't have to exist: a move counts whatever + // order it lands in against concurrent column changes, and a + // card whose column is gone shows in a fallback one. + if position::is_valid(&position) + && position.len() <= MAX_POSITION_LEN + && !column.is_empty() + && column.len() <= MAX_COLUMN_ID_LEN + { + self.cards.insert( + card, + Placement { + column, + position, + moved_by: author, + moved_at: timestamp, + }, + ); + } + } + Action::Remove { card } => { + self.cards.remove(&card); + } + } + } +} + +pub fn valid_columns(columns: &[Column]) -> bool { + !columns.is_empty() + && columns.len() <= MAX_COLUMNS + && columns.iter().all(|c| { + !c.id.is_empty() + && c.id.len() <= MAX_COLUMN_ID_LEN + && !c.name.is_empty() + && c.name.len() <= MAX_NAME_LEN + }) + && columns + .iter() + .enumerate() + .all(|(i, c)| columns[..i].iter().all(|d| d.id != c.id)) +} + +fn authorized( + board: &Board, + action: &Action, + author: &ActorId, + parents: &[Oid], + doc: &DocAt, + repo: &R, +) -> bool +where + R: ReadRepository + + cob::change::Storage< + ObjectId = Oid, + Parent = Oid, + PublicKey = crypto::PublicKey, + Signature = crypto::Signature, + >, +{ + let delegate = doc.is_delegate(&Did::from(*author)); + let Action::Move { + card, + column, + proof, + .. + } = action + else { + return delegate; + }; + let issue = card.type_name == *cob::issue::TYPENAME; + if !issue && card.type_name != *cob::patch::TYPENAME { + return false; + } + let target = board.closes(column); + if target.is_some() && !issue { + return false; + } + // A card from another repository can't be loaded, so only its type name + // is checked, for delegates too. + if card.rid != repo.id() { + return delegate; + } + // The card's root has to travel with the op, or its later arrival could + // change this verdict without the board's tips changing. + if !parents.contains(&*card.oid) { + return false; + } + + let Ok(root) = cob::change::Storage::load(repo, *card.oid) else { + return false; + }; + if !root.valid_signatures() + || root.manifest.type_name != card.type_name + || !root.parents.is_empty() + { + return false; + } + if delegate || *root.author() == *author { + return true; + } + + // Others may only move a card between columns that do the same thing to + // it, so the move can't close, reopen or change why an issue is closed. + let source = board.placement(card).and_then(|p| board.closes(&p.column)); + if !issue || source != target { + return false; + } + + // The proof, too, has to travel with the op. + proof.is_some_and(|proof| parents.contains(&proof) && assigned(&card.oid, &proof, author, repo)) +} + +/// The identity commit that `op` names, if it's one of the op's git parents. +/// The commits an op relies on must be its git parents, not just named in its +/// trailers, so every peer evaluates it the same way. +fn linked_identity(op: Oid, identity: Option, repo: &R) -> Option { + let head = identity?; + let commit = repo.commit(op).ok()?; + let linked = commit.parent_ids().any(|parent| Oid::from(parent) == head); + linked.then_some(head) +} + +/// Whether `proof` is an operation of `issue` in which a delegate assigned +/// `author`. +fn assigned(issue: &Oid, proof: &Oid, author: &ActorId, repo: &R) -> bool +where + R: ReadRepository + + cob::change::Storage< + ObjectId = Oid, + Parent = Oid, + PublicKey = crypto::PublicKey, + Signature = crypto::Signature, + >, +{ + let Ok(entry) = cob::change::Storage::load(repo, *proof) else { + return false; + }; + if !entry.valid_signatures() || entry.manifest.type_name != *cob::issue::TYPENAME { + return false; + } + let Ok(op) = Op::::try_from(&entry) else { + return false; + }; + if proof != issue && !matches!(repo.is_ancestor_of(*issue, *proof), Ok(true)) { + return false; + } + if linked_identity(*proof, op.identity, repo).is_none() { + return false; + } + let Ok(Some(doc)) = op.identity_doc(repo) else { + return false; + }; + if !doc.is_delegate(&Did::from(op.author)) { + return false; + } + let author = Did::from(*author); + + op.actions.iter().any(|action| match action { + cob::issue::Action::Assign { assignees } => assignees.contains(&author), + _ => false, + }) +} + +impl CobWithType for Board { + fn type_name() -> &'static TypeName { + &TYPENAME + } +} + +impl store::Cob for Board { + type Action = Action; + type Error = Error; + + fn from_root(op: Op, repo: &R) -> Result { + if linked_identity(op.id, op.identity, repo).is_none() { + return Err(Error::MissingIdentity); + } + let doc = op.identity_doc(repo)?.ok_or(Error::MissingIdentity)?; + if !doc.is_delegate(&Did::from(op.author)) { + return Err(Error::NotDelegate); + } + let mut actions = op.actions.into_iter(); + let Some(Action::Edit { name }) = actions.next() else { + return Err(Error::Init); + }; + if name.is_empty() || name.len() > MAX_NAME_LEN { + return Err(Error::Invalid("the board name is empty or too long")); + } + let mut board = Board { + name, + created_at: op.timestamp, + columns: default_columns(), + cards: BTreeMap::new(), + }; + // The root's author is a delegate, so every action in it is allowed. + for action in actions { + board.apply_action(action, op.author, op.timestamp); + } + + Ok(board) + } + + fn op<'a, R: ReadRepository, I: IntoIterator>( + &mut self, + _op: Op, + _concurrent: I, + _repo: &R, + ) -> Result<(), Error> { + // Never called: `store::Store` requires `Cob`, but boards are evaluated + // through `Evaluate`, whose repository can load the linked objects. + Err(Error::Invalid("boards are evaluated through `Evaluate`")) + } +} + +impl Evaluate for Board +where + R: ReadRepository + + cob::change::Storage< + ObjectId = Oid, + Parent = Oid, + PublicKey = crypto::PublicKey, + Signature = crypto::Signature, + >, +{ + type Error = Error; + + fn init(entry: &cob::Entry, repo: &R) -> Result { + // Checked before decoding, as a newer format's actions may not decode. + if !supported(entry.manifest.version) { + return Err(Error::Version(entry.manifest.version.into())); + } + let op = Op::try_from(entry)?; + ::from_root(op, repo) + } + + fn apply<'a, I: Iterator>( + &mut self, + entry: &cob::Entry, + _concurrent: I, + repo: &R, + ) -> Result<(), Error> { + if !supported(entry.manifest.version) { + log::debug!(target: "board", "Ignoring operation {} in a newer format", entry.id); + return Ok(()); + } + let op = match Op::::try_from(entry) { + Ok(op) => op, + Err(e) => { + log::warn!(target: "board", "Ignoring operation {} that doesn't decode: {e}", entry.id); + return Ok(()); + } + }; + // The commits an op relies on must be its git parents, not just named + // in its trailers, so every peer evaluates it the same way. + let parents = match cob::change::Storage::parents_of(repo, &op.id) { + Ok(parents) => parents, + Err(e) => { + log::warn!(target: "board", "Ignoring operation {} whose parents don't load: {e}", op.id); + return Ok(()); + } + }; + if !op.identity.is_some_and(|head| parents.contains(&head)) { + return Ok(()); + } + let doc = match op.identity_doc(repo) { + Ok(Some(doc)) => doc, + Ok(None) => return Ok(()), + Err(e) => { + log::warn!(target: "board", "Ignoring operation {} without an identity: {e}", op.id); + return Ok(()); + } + }; + for action in op.actions { + if authorized(self, &action, &op.author, &parents, &doc, repo) { + self.apply_action(action, op.author, op.timestamp); + } else { + log::debug!(target: "board", "Ignoring unauthorized action in {}", op.id); + } + } + + Ok(()) + } +} + +pub struct Boards<'a, R, A> { + raw: store::Store<'a, Board, R, A>, +} + +impl<'a, R, A> Deref for Boards<'a, R, A> { + type Target = store::Store<'a, Board, R, A>; + + fn deref(&self) -> &Self::Target { + &self.raw + } +} + +impl<'a, R, A> Boards<'a, R, A> +where + R: ReadRepository + cob::Store, + A: access::Access + 'a, +{ + pub fn open(repository: &'a R, access: A) -> Result { + let identity = repository.identity_head()?; + let raw = store::Store::open_for(&TYPENAME, repository, access)?.identity(identity); + + Ok(Self { raw }) + } + + /// Whether `card` may go on this repository's boards. A move writes the + /// card's repository and id into the board's history for everyone who + /// replicates it, so a private repository's cards only go on its own + /// boards. The evaluator can't check this, so it's checked here, before + /// writing. + pub fn shareable(&self, storage: &S, card: &Card) -> Result<(), ShareError> { + let rid = self.raw.as_ref().id(); + if card.rid == rid { + return Ok(()); + } + let private = storage + .repository(card.rid) + .and_then(|repo| Ok(repo.identity_doc()?.is_private())) + .map_err(|_| ShareError::Unavailable(card.rid))?; + if private { + return Err(ShareError::Private(card.rid)); + } + Ok(()) + } +} + +impl<'a, R, A> Boards<'a, R, A> +where + R: ReadRepository + + cob::Store + + cob::change::Storage< + ObjectId = Oid, + Parent = Oid, + PublicKey = crypto::PublicKey, + Signature = crypto::Signature, + >, + A: access::Access + 'a, +{ + pub fn get(&self, id: &BoardId) -> Result, store::Error> { + self.raw.get(id) + } + + pub fn list(&self) -> Result, store::Error> { + self.raw.all()?.collect() + } + + /// Whether `author` may carry out `action` on `board` as of the + /// repository's current identity. + pub fn authorized( + &self, + board: &Board, + action: &Action, + author: &ActorId, + ) -> Result { + let repo = self.raw.as_ref(); + let doc = repo.identity_doc()?; + + Ok(authorized( + board, + action, + author, + &action.parents(), + &doc, + repo, + )) + } + + /// A move of `card` that links the card's object when it lives in this + /// repository. + pub fn move_action( + &self, + card: Card, + column: String, + position: String, + proof: Option, + ) -> Action { + let local = card.rid == self.raw.as_ref().id(); + Action::Move { + card, + column, + position, + proof, + local, + } + } +} + +impl<'a, R, S> Boards<'a, R, access::WriteAs<'a, S>> +where + R: WriteRepository + + SignRepository + + cob::Store + + cob::change::Storage< + ObjectId = Oid, + Parent = Oid, + PublicKey = crypto::PublicKey, + Signature = crypto::Signature, + >, + S: crypto::Signer, +{ + pub fn create(&mut self, name: &str) -> Result<(BoardId, Board), store::Error> { + store::Transaction::initial("Create board", &mut self.raw, |tx, _| { + tx.push(Action::Edit { + name: name.to_string(), + }) + }) + } + + pub fn rename(&mut self, id: &BoardId, name: &str) -> Result { + self.transact( + id, + "Rename board", + vec![Action::Edit { + name: name.to_string(), + }], + ) + } + + pub fn set_columns( + &mut self, + id: &BoardId, + columns: Vec, + ) -> Result { + self.transact(id, "Set board columns", vec![Action::Columns { columns }]) + } + + /// Applies `actions` as one operation, so they cost a single update of + /// the signed refs. Refuses, writing nothing, if a move names a card + /// that isn't [`shareable`](Self::shareable). + pub fn update( + &mut self, + storage: &T, + id: &BoardId, + actions: Vec, + ) -> Result { + for action in &actions { + if let Action::Move { card, .. } = action { + self.shareable(storage, card)?; + } + } + Ok(self.transact(id, "Update board", actions)?) + } + + fn transact( + &mut self, + id: &BoardId, + message: &str, + actions: Vec, + ) -> Result { + let mut tx = store::Transaction::::default(); + tx.extend(actions)?; + let (board, _) = tx.commit(message, *id, &mut self.raw)?; + + Ok(board) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod test; diff --git a/desktop/crates/radicle-board/src/position.rs b/desktop/crates/radicle-board/src/position.rs new file mode 100644 index 000000000..18efdc7fd --- /dev/null +++ b/desktop/crates/radicle-board/src/position.rs @@ -0,0 +1,163 @@ +//! Fractional positions for ordering cards within a column: base-62 strings +//! compared lexicographically. A card is placed between two neighbours by +//! generating a key that sorts between theirs, so a move never rewrites +//! other cards' positions. + +const DIGITS: &[u8] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; +const BASE: usize = DIGITS.len(); + +fn digit(c: u8) -> Option { + DIGITS.iter().position(|d| *d == c) +} + +/// Keys never end in the zero digit, which guarantees there is always room +/// for a key before them. +pub fn is_valid(position: &str) -> bool { + !position.is_empty() && position.bytes().all(|c| digit(c).is_some()) && !position.ends_with('0') +} + +/// A key that sorts strictly between `before` and `after`, where `None` is +/// the start or end of the column. `None` when a bound is invalid or `before` +/// does not sort before `after`. +pub fn between(before: Option<&str>, after: Option<&str>) -> Option { + if before.is_some_and(|b| !is_valid(b)) || after.is_some_and(|a| !is_valid(a)) { + return None; + } + if let (Some(b), Some(a)) = (before, after) + && b >= a + { + return None; + } + + let low = before.unwrap_or("").as_bytes(); + let mut high = after.map(str::as_bytes); + let mut key = Vec::new(); + + for i in 0.. { + let lo = low.get(i).and_then(|c| digit(*c)).unwrap_or(0); + let hi = match high { + Some(h) => h.get(i).and_then(|c| digit(*c)).unwrap_or(0), + None => BASE, + }; + + if hi > lo + 1 { + key.push(DIGITS[(lo + hi) / 2]); + break; + } + key.push(DIGITS[lo]); + if hi == lo + 1 { + // Every key with this prefix already sorts below `high`, so the + // remaining digits are only bounded by `low`. + high = None; + } + } + + if key.len() > crate::MAX_POSITION_LEN { + return None; + } + String::from_utf8(key).ok() +} + +/// The bounds for a new key at `index` among `positions`, which are in column +/// order. Neighbours that share a key leave no room between them, so the new +/// key goes just after the upper one and stays below the next distinct key. +pub fn neighbours<'a>(positions: &[&'a str], index: usize) -> (Option<&'a str>, Option<&'a str>) { + let before = index.checked_sub(1).and_then(|i| positions.get(i).copied()); + let after = positions.get(index).copied(); + match (before, after) { + (Some(b), Some(a)) if b >= a => { + let next = positions[index..].iter().copied().find(|p| *p > b); + (Some(b), next) + } + _ => (before, after), + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod test { + use serde::Deserialize; + + use super::{between, is_valid, neighbours}; + + // Shared with the TypeScript implementation, which reads the same file, + // so that both generate identical keys. + const FIXTURE: &str = include_str!("../tests/fixtures/positions.json"); + + #[derive(Deserialize)] + struct Fixture { + between: Vec, + neighbours: Vec, + } + + #[derive(Deserialize)] + struct Between { + before: Option, + after: Option, + key: Option, + } + + #[derive(Deserialize)] + struct Neighbours { + positions: Vec, + index: usize, + before: Option, + after: Option, + key: Option, + } + + fn fixture() -> Fixture { + serde_json::from_str(FIXTURE).unwrap() + } + + #[test] + fn between_matches_the_fixture() { + for case in fixture().between { + assert_eq!( + between(case.before.as_deref(), case.after.as_deref()), + case.key, + "between({:?}, {:?})", + case.before, + case.after + ); + } + } + + #[test] + fn neighbours_match_the_fixture() { + for case in fixture().neighbours { + let positions: Vec<&str> = case.positions.iter().map(String::as_str).collect(); + let (before, after) = neighbours(&positions, case.index); + assert_eq!( + (before, after), + (case.before.as_deref(), case.after.as_deref()), + "neighbours({:?}, {})", + case.positions, + case.index + ); + assert_eq!(between(before, after), case.key); + } + } + + #[test] + fn keys_sort_between_their_bounds() { + let mut keys = vec![between(None, None).unwrap()]; + for _ in 0..200 { + let last = keys.last().unwrap().clone(); + keys.push(between(Some(&last), None).unwrap()); + } + for _ in 0..200 { + let first = keys.first().unwrap().clone(); + keys.insert(0, between(None, Some(&first)).unwrap()); + } + for _ in 0..100 { + let (a, b) = (keys[10].clone(), keys[11].clone()); + keys.insert(11, between(Some(&a), Some(&b)).unwrap()); + } + + for pair in keys.windows(2) { + assert!(pair[0] < pair[1], "{} < {}", pair[0], pair[1]); + } + assert!(keys.iter().all(|k| is_valid(k))); + } +} diff --git a/desktop/crates/radicle-board/src/test.rs b/desktop/crates/radicle-board/src/test.rs new file mode 100644 index 000000000..200636221 --- /dev/null +++ b/desktop/crates/radicle-board/src/test.rs @@ -0,0 +1,1339 @@ +use radicle::cob::cache::NoCache; +use radicle::cob::issue::Issues; +use radicle::cob::patch::{MergeTarget, Patches}; +use radicle::cob::store::access::{ReadOnly, WriteAs}; +use radicle::cob::{Contents, ObjectId, Title, Update}; +use radicle::crypto::{Signer as _, SigningKey}; +use radicle::git::{Oid, raw}; +use radicle::identity::{Did, RepoId, Visibility}; +use radicle::storage::{ReadRepository, ReadStorage}; +use radicle::test; + +use crate::{Action, Board, BoardId, Boards, Card, Column, position}; + +fn issue_card(repo: &impl ReadRepository, oid: ObjectId) -> Card { + Card { + rid: repo.id(), + type_name: radicle::cob::issue::TYPENAME.clone(), + oid, + } +} + +#[allow(clippy::let_and_return)] +fn open_issue( + repo: &radicle::storage::git::Repository, + signer: &SigningKey, + title: &str, +) -> ObjectId { + 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 id = *issue.id(); + id +} + +fn patch_card(repo: &impl ReadRepository, oid: ObjectId) -> Card { + Card { + rid: repo.id(), + type_name: radicle::cob::patch::TYPENAME.clone(), + oid, + } +} + +fn open_patch(repo: &test::setup::NodeRepo, signer: &SigningKey, title: &str) -> ObjectId { + let branch = repo.checkout().branch_with([("README", b"hello")]); + let mut patches = Patches::open(&**repo, WriteAs::new(signer)).unwrap(); + let mut cache = NoCache; + let patch = patches + .create( + Title::new(title).unwrap(), + "", + MergeTarget::Delegates, + branch.base, + branch.oid, + &[], + &mut cache, + ) + .unwrap(); + *patch.id() +} + +/// Another repository in `node`'s storage. +fn other_repo(node: &test::setup::Node, name: &str, visibility: Visibility) -> RepoId { + let (working, _) = test::fixtures::repository(node.root.join(name)); + let (rid, _, _) = radicle::rad::init( + &working, + name.try_into().unwrap(), + "", + "master".try_into().unwrap(), + visibility, + &node.signer, + &node.storage, + ) + .unwrap(); + rid +} + +fn first() -> String { + position::between(None, None).unwrap() +} + +type WritableBoards<'a> = Boards<'a, radicle::storage::git::Repository, WriteAs<'a, SigningKey>>; + +fn move_card( + storage: &impl ReadStorage, + boards: &mut WritableBoards, + id: &BoardId, + card: Card, + column: &str, + position: String, + proof: Option, +) -> Board { + let action = boards.move_action(card, column.into(), position, proof); + boards.update(storage, id, vec![action]).unwrap() +} + +/// A copy of the operation `op` whose signature is the one from `signed_by`, +/// made over that other operation's contents, so it doesn't verify for this +/// one. The key named in it is the one that signed `signed_by`. +fn forge(repo: &raw::Repository, op: Oid, signed_by: Oid) -> Oid { + let genuine = repo.find_commit(op.into()).unwrap(); + let other = repo.find_commit(signed_by.into()).unwrap(); + let signature = other.header_field_bytes("gpgsig").unwrap(); + let parents: Vec<_> = genuine.parents().collect(); + let parents: Vec<_> = parents.iter().collect(); + let buffer = repo + .commit_create_buffer( + &genuine.author(), + &genuine.committer(), + genuine.message().unwrap(), + &genuine.tree().unwrap(), + &parents, + ) + .unwrap(); + repo.commit_signed( + buffer.as_str().unwrap(), + signature.as_str().unwrap(), + Some("gpgsig"), + ) + .unwrap() + .into() +} + +#[test] +fn delegate_moves_cards() { + let test::setup::NodeWithRepo { + node: alice, repo, .. + } = test::setup::NodeWithRepo::default(); + let issue = open_issue(&repo, &alice.signer, "Alice's issue"); + let mut boards = Boards::open(&*repo, WriteAs::new(&alice.signer)).unwrap(); + let (id, board) = boards.create("Roadmap").unwrap(); + assert_eq!(board.name(), "Roadmap"); + assert!(board.cards().is_empty()); + + let card = issue_card(&*repo, issue); + let board = move_card( + &alice.storage, + &mut boards, + &id, + card.clone(), + "todo", + first(), + None, + ); + assert_eq!(board.placement(&card).unwrap().column, "todo"); + + let board = boards + .update( + &alice.storage, + &id, + vec![Action::Remove { card: card.clone() }], + ) + .unwrap(); + assert!(board.placement(&card).is_none()); + + let read = Boards::open(&*repo, ReadOnly).unwrap(); + assert_eq!(read.list().unwrap().len(), 1); +} + +#[test] +fn moves_count_whatever_the_columns() { + let test::setup::NodeWithRepo { + node: alice, repo, .. + } = test::setup::NodeWithRepo::default(); + let issue = open_issue(&repo, &alice.signer, "Issue"); + let mut boards = Boards::open(&*repo, WriteAs::new(&alice.signer)).unwrap(); + let (id, _) = boards.create("Roadmap").unwrap(); + let card = issue_card(&*repo, issue); + let review = Column::new("review", "Review", None); + + // Into a column that a concurrent change hasn't added here yet. + let board = move_card( + &alice.storage, + &mut boards, + &id, + card.clone(), + "review", + first(), + None, + ); + assert_eq!(board.placement(&card).unwrap().column, "review"); + let mut columns = crate::default_columns(); + columns.push(review); + let board = boards.set_columns(&id, columns).unwrap(); + assert_eq!(board.placement(&card).unwrap().column, "review"); + + // And the column being removed afterwards keeps the move. + let board = boards.set_columns(&id, crate::default_columns()).unwrap(); + assert_eq!(board.placement(&card).unwrap().column, "review"); + + let long = "i".repeat(crate::MAX_COLUMN_ID_LEN + 1); + for column in ["", long.as_str()] { + let board = move_card( + &alice.storage, + &mut boards, + &id, + card.clone(), + column, + first(), + None, + ); + assert_eq!(board.placement(&card).unwrap().column, "review"); + } +} + +#[test] +fn assignees_move_into_unknown_columns_as_open_ones() { + let test::setup::NodeWithRepo { + node: alice, repo, .. + } = test::setup::NodeWithRepo::default(); + let bob = SigningKey::mock(1); + let carol = SigningKey::mock(2); + let issue = open_issue(&repo, &carol, "Carol's issue"); + let assignment = Issues::open(&*repo, WriteAs::new(&alice.signer)) + .unwrap() + .get_mut(&issue, &mut NoCache) + .unwrap() + .assign([Did::from(*bob.public_key())]) + .unwrap(); + let mut delegate = Boards::open(&*repo, WriteAs::new(&alice.signer)).unwrap(); + let (id, _) = delegate.create("Roadmap").unwrap(); + let card = issue_card(&*repo, issue); + + let mut assignee = Boards::open(&*repo, WriteAs::new(&bob)).unwrap(); + let board = move_card( + &alice.storage, + &mut assignee, + &id, + card.clone(), + "qa", + first(), + Some(assignment), + ); + assert_eq!(board.placement(&card).unwrap().column, "qa"); + + // Once "qa" closes issues, only the author can move the card out of it. + let mut columns = crate::default_columns(); + columns.push(Column::new("qa", "QA", Some(crate::Closes::Solved))); + delegate.set_columns(&id, columns).unwrap(); + let board = move_card( + &alice.storage, + &mut assignee, + &id, + card.clone(), + "todo", + first(), + Some(assignment), + ); + assert_eq!(board.placement(&card).unwrap().column, "qa"); + + let mut author = Boards::open(&*repo, WriteAs::new(&carol)).unwrap(); + let board = move_card( + &alice.storage, + &mut author, + &id, + card.clone(), + "todo", + first(), + None, + ); + assert_eq!(board.placement(&card).unwrap().column, "todo"); +} + +#[test] +fn only_delegates_create_boards() { + let test::setup::NodeWithRepo { node: _alice, repo } = test::setup::NodeWithRepo::default(); + let bob = SigningKey::mock(1); + let mut boards = Boards::open(&*repo, WriteAs::new(&bob)).unwrap(); + + assert!(boards.create("Bob's board").is_err()); +} + +#[test] +fn others_cannot_move_cards() { + let test::setup::NodeWithRepo { + node: alice, repo, .. + } = test::setup::NodeWithRepo::default(); + let issue = open_issue(&repo, &alice.signer, "Alice's issue"); + let (id, _) = Boards::open(&*repo, WriteAs::new(&alice.signer)) + .unwrap() + .create("Roadmap") + .unwrap(); + + let bob = SigningKey::mock(1); + let mut boards = Boards::open(&*repo, WriteAs::new(&bob)).unwrap(); + let card = issue_card(&*repo, issue); + let board = move_card( + &alice.storage, + &mut boards, + &id, + card.clone(), + "todo", + first(), + None, + ); + assert!(board.placement(&card).is_none()); + + let board = boards + .set_columns(&id, vec![crate::Column::new("mine", "Mine", None)]) + .unwrap(); + assert_eq!(board.columns(), crate::default_columns().as_slice()); +} + +#[test] +fn authors_move_their_own_cards() { + let test::setup::NodeWithRepo { + node: alice, repo, .. + } = test::setup::NodeWithRepo::default(); + let (id, _) = Boards::open(&*repo, WriteAs::new(&alice.signer)) + .unwrap() + .create("Roadmap") + .unwrap(); + + let bob = SigningKey::mock(1); + let issue = open_issue(&repo, &bob, "Bob's issue"); + let mut boards = Boards::open(&*repo, WriteAs::new(&bob)).unwrap(); + let card = issue_card(&*repo, issue); + let board = move_card( + &alice.storage, + &mut boards, + &id, + card.clone(), + "in-progress", + first(), + None, + ); + assert_eq!(board.placement(&card).unwrap().column, "in-progress"); +} + +#[test] +fn assignees_move_cards_with_proof() { + let test::setup::NodeWithRepo { + node: alice, repo, .. + } = test::setup::NodeWithRepo::default(); + let bob = SigningKey::mock(1); + let issue = open_issue(&repo, &alice.signer, "Alice's issue"); + let other = open_issue(&repo, &alice.signer, "Another issue"); + + let mut issues = Issues::open(&*repo, WriteAs::new(&alice.signer)).unwrap(); + let assignment = issues + .get_mut(&issue, &mut NoCache) + .unwrap() + .assign([Did::from(*bob.public_key())]) + .unwrap(); + let other_assignment = issues + .get_mut(&other, &mut NoCache) + .unwrap() + .assign([Did::from(*bob.public_key())]) + .unwrap(); + + let (id, _) = Boards::open(&*repo, WriteAs::new(&alice.signer)) + .unwrap() + .create("Roadmap") + .unwrap(); + let mut boards = Boards::open(&*repo, WriteAs::new(&bob)).unwrap(); + let card = issue_card(&*repo, issue); + + // Without proof, or with an assignment to a different issue, the move is + // ignored. + let board = move_card( + &alice.storage, + &mut boards, + &id, + card.clone(), + "todo", + first(), + None, + ); + assert!(board.placement(&card).is_none()); + let board = move_card( + &alice.storage, + &mut boards, + &id, + card.clone(), + "todo", + first(), + Some(other_assignment), + ); + assert!(board.placement(&card).is_none()); + + let board = move_card( + &alice.storage, + &mut boards, + &id, + card.clone(), + "todo", + first(), + Some(assignment), + ); + assert_eq!(board.placement(&card).unwrap().column, "todo"); +} + +#[test] +fn proofs_that_do_not_travel_with_the_move_are_ignored() { + let test::setup::NodeWithRepo { + node: alice, repo, .. + } = test::setup::NodeWithRepo::default(); + let bob = SigningKey::mock(1); + let issue = open_issue(&repo, &alice.signer, "Alice's issue"); + let assignment = Issues::open(&*repo, WriteAs::new(&alice.signer)) + .unwrap() + .get_mut(&issue, &mut NoCache) + .unwrap() + .assign([Did::from(*bob.public_key())]) + .unwrap(); + let (id, _) = Boards::open(&*repo, WriteAs::new(&alice.signer)) + .unwrap() + .create("Roadmap") + .unwrap(); + let card = issue_card(&*repo, issue); + + // Written by a client that names the proof but only links the card. + let action = Action::Move { + card: card.clone(), + column: "todo".into(), + position: first(), + proof: Some(assignment), + local: true, + }; + radicle::cob::update::( + &*repo, + &bob, + Some(repo.identity_head().unwrap()), + vec![*issue], + bob.public_key(), + Update { + changes: Contents::new(serde_json::to_vec(&action).unwrap()), + object_id: id, + type_name: crate::TYPENAME.clone(), + message: "Move".into(), + embeds: vec![], + }, + ) + .unwrap(); + + let read = Boards::open(&*repo, ReadOnly).unwrap(); + assert!(read.get(&id).unwrap().unwrap().placement(&card).is_none()); + + let mut boards = Boards::open(&*repo, WriteAs::new(&bob)).unwrap(); + let board = move_card( + &alice.storage, + &mut boards, + &id, + card.clone(), + "todo", + first(), + Some(assignment), + ); + assert_eq!(board.placement(&card).unwrap().column, "todo"); +} + +#[test] +fn self_assignment_is_not_proof() { + let test::setup::NodeWithRepo { + node: alice, repo, .. + } = test::setup::NodeWithRepo::default(); + let bob = SigningKey::mock(1); + let issue = open_issue(&repo, &alice.signer, "Alice's issue"); + let (id, _) = Boards::open(&*repo, WriteAs::new(&alice.signer)) + .unwrap() + .create("Roadmap") + .unwrap(); + + // Bob's own assignment is rejected by the issue, so he has no operation to + // offer as proof; a comment of his on the issue isn't one either. + let mut issues = Issues::open(&*repo, WriteAs::new(&bob)).unwrap(); + let mut cache = NoCache; + let mut handle = issues.get_mut(&issue, &mut cache).unwrap(); + assert!(handle.assign([Did::from(*bob.public_key())]).is_err()); + let comment = handle.comment("On it", *issue, []).unwrap(); + + let mut boards = Boards::open(&*repo, WriteAs::new(&bob)).unwrap(); + let card = issue_card(&*repo, issue); + let board = move_card( + &alice.storage, + &mut boards, + &id, + card.clone(), + "todo", + first(), + Some(comment), + ); + assert!(board.placement(&card).is_none()); +} + +#[test] +fn cards_sort_by_column_then_position() { + let test::setup::NodeWithRepo { + node: alice, repo, .. + } = test::setup::NodeWithRepo::default(); + let a = issue_card(&*repo, open_issue(&repo, &alice.signer, "A")); + let b = issue_card(&*repo, open_issue(&repo, &alice.signer, "B")); + let c = issue_card(&*repo, open_issue(&repo, &alice.signer, "C")); + let mut boards = Boards::open(&*repo, WriteAs::new(&alice.signer)).unwrap(); + let (id, _) = boards.create("Roadmap").unwrap(); + + let p1 = first(); + let p2 = position::between(Some(&p1), None).unwrap(); + let p0 = position::between(None, Some(&p1)).unwrap(); + move_card( + &alice.storage, + &mut boards, + &id, + a.clone(), + "todo", + p1, + None, + ); + move_card( + &alice.storage, + &mut boards, + &id, + b.clone(), + "todo", + p2, + None, + ); + move_card( + &alice.storage, + &mut boards, + &id, + c.clone(), + "backlog", + p0, + None, + ); + + let board = boards.get(&id).unwrap().unwrap(); + let order: Vec<_> = board + .cards() + .into_iter() + .map(|(card, _)| card.clone()) + .collect(); + assert_eq!(order, vec![c, a, b]); +} + +#[test] +fn assignment_at_creation_is_proof() { + let test::setup::NodeWithRepo { node: alice, repo } = test::setup::NodeWithRepo::default(); + let bob = SigningKey::mock(1); + let mut issues = Issues::open(&*repo, WriteAs::new(&alice.signer)).unwrap(); + let mut cache = NoCache; + let issue = *issues + .create( + Title::new("Assigned from the start").unwrap(), + "", + &[], + &[Did::from(*bob.public_key())], + [], + &mut cache, + ) + .unwrap() + .id(); + + let (id, _) = Boards::open(&*repo, WriteAs::new(&alice.signer)) + .unwrap() + .create("Roadmap") + .unwrap(); + let mut boards = Boards::open(&*repo, WriteAs::new(&bob)).unwrap(); + let card = issue_card(&*repo, issue); + let board = move_card( + &alice.storage, + &mut boards, + &id, + card.clone(), + "todo", + first(), + Some(*issue), + ); + assert_eq!(board.placement(&card).unwrap().column, "todo"); +} + +#[test] +fn a_batch_is_one_operation() { + let test::setup::NodeWithRepo { + node: alice, repo, .. + } = test::setup::NodeWithRepo::default(); + let first_issue = open_issue(&repo, &alice.signer, "First"); + let second_issue = open_issue(&repo, &alice.signer, "Second"); + let mut boards = Boards::open(&*repo, WriteAs::new(&alice.signer)).unwrap(); + let (id, _) = boards.create("Roadmap").unwrap(); + let a = issue_card(&*repo, first_issue); + let b = issue_card(&*repo, second_issue); + + let actions = vec![ + boards.move_action(a.clone(), "todo".into(), first(), None), + boards.move_action(b.clone(), "done".into(), first(), None), + ]; + let board = boards.update(&alice.storage, &id, actions).unwrap(); + assert_eq!(board.placement(&a).unwrap().column, "todo"); + assert_eq!(board.placement(&b).unwrap().column, "done"); + + let ops = radicle::cob::store::ops(&id, &crate::TYPENAME, &*repo).unwrap(); + assert_eq!(ops.len(), 2); + let update = ops.last(); + assert_eq!(update.actions.len(), 2); + assert!(update.related.contains(&*first_issue)); + assert!(update.related.contains(&*second_issue)); +} + +#[test] +fn unauthorized_actions_in_a_batch_are_skipped() { + let test::setup::NodeWithRepo { + node: alice, repo, .. + } = test::setup::NodeWithRepo::default(); + let alices = open_issue(&repo, &alice.signer, "Alice's issue"); + let (id, _) = Boards::open(&*repo, WriteAs::new(&alice.signer)) + .unwrap() + .create("Roadmap") + .unwrap(); + + let bob = SigningKey::mock(1); + let bobs = open_issue(&repo, &bob, "Bob's issue"); + let mut boards = Boards::open(&*repo, WriteAs::new(&bob)).unwrap(); + let own = issue_card(&*repo, bobs); + let other = issue_card(&*repo, alices); + let actions = vec![ + boards.move_action(own.clone(), "todo".into(), first(), None), + boards.move_action(other.clone(), "todo".into(), first(), None), + crate::Action::Columns { + columns: vec![crate::Column::new("mine", "Mine", None)], + }, + ]; + let board = boards.update(&alice.storage, &id, actions).unwrap(); + + assert_eq!(board.placement(&own).unwrap().column, "todo"); + assert!(board.placement(&other).is_none()); + assert_eq!(board.columns(), crate::default_columns().as_slice()); +} + +#[test] +fn authorization_can_be_checked_before_writing() { + let test::setup::NodeWithRepo { + node: alice, repo, .. + } = test::setup::NodeWithRepo::default(); + let alices = open_issue(&repo, &alice.signer, "Alice's issue"); + let bob = SigningKey::mock(1); + let bobs = open_issue(&repo, &bob, "Bob's issue"); + + let (id, board) = Boards::open(&*repo, WriteAs::new(&alice.signer)) + .unwrap() + .create("Roadmap") + .unwrap(); + let boards = Boards::open(&*repo, WriteAs::new(&bob)).unwrap(); + let own = boards.move_action(issue_card(&*repo, bobs), "todo".into(), first(), None); + let other = boards.move_action(issue_card(&*repo, alices), "todo".into(), first(), None); + let rename = crate::Action::Edit { + name: "Mine".into(), + }; + let bob_key = *bob.public_key(); + let alice_key = *alice.signer.public_key(); + + assert!(boards.authorized(&board, &own, &bob_key).unwrap()); + assert!(!boards.authorized(&board, &other, &bob_key).unwrap()); + assert!(!boards.authorized(&board, &rename, &bob_key).unwrap()); + assert!(boards.authorized(&board, &other, &alice_key).unwrap()); + assert!(boards.authorized(&board, &rename, &alice_key).unwrap()); + assert_eq!(boards.get(&id).unwrap().unwrap(), board); +} + +#[test] +fn operations_are_written_in_the_current_version() { + let test::setup::NodeWithRepo { + node: alice, repo, .. + } = test::setup::NodeWithRepo::default(); + let mut boards = Boards::open(&*repo, WriteAs::new(&alice.signer)).unwrap(); + let (id, _) = boards.create("Roadmap").unwrap(); + boards.rename(&id, "Plans").unwrap(); + + let ops = radicle::cob::store::ops(&id, &crate::TYPENAME, &*repo).unwrap(); + assert!( + ops.iter() + .all(|op| usize::from(op.manifest.version) == crate::VERSION) + ); +} + +#[test] +fn newer_formats_are_unsupported() { + let current = radicle::cob::Version::new(crate::VERSION).unwrap(); + let newer = radicle::cob::Version::new(crate::VERSION + 1).unwrap(); + assert!(crate::supported(current)); + assert!(!crate::supported(newer)); +} + +#[test] +fn forged_proofs_are_rejected() { + let test::setup::NodeWithRepo { + node: alice, repo, .. + } = test::setup::NodeWithRepo::default(); + let bob = SigningKey::mock(1); + let issue = open_issue(&repo, &alice.signer, "Alice's issue"); + let mut issues = Issues::open(&*repo, WriteAs::new(&alice.signer)).unwrap(); + let assignment = issues + .get_mut(&issue, &mut NoCache) + .unwrap() + .assign([Did::from(*bob.public_key())]) + .unwrap(); + let (id, _) = Boards::open(&*repo, WriteAs::new(&alice.signer)) + .unwrap() + .create("Roadmap") + .unwrap(); + + // The forged proof has the assignment's contents under a signature that + // doesn't verify for them. + let forged = forge(&repo.backend, assignment, *issue); + let mut boards = Boards::open(&*repo, WriteAs::new(&bob)).unwrap(); + let card = issue_card(&*repo, issue); + let board = move_card( + &alice.storage, + &mut boards, + &id, + card.clone(), + "todo", + first(), + Some(forged), + ); + assert!(board.placement(&card).is_none()); + + let board = move_card( + &alice.storage, + &mut boards, + &id, + card.clone(), + "todo", + first(), + Some(assignment), + ); + assert_eq!(board.placement(&card).unwrap().column, "todo"); +} + +#[test] +fn forged_card_roots_are_rejected() { + let test::setup::NodeWithRepo { + node: alice, repo, .. + } = test::setup::NodeWithRepo::default(); + let bob = SigningKey::mock(1); + let alices = open_issue(&repo, &alice.signer, "Alice's issue"); + let bobs = open_issue(&repo, &bob, "Bob's issue"); + let (id, _) = Boards::open(&*repo, WriteAs::new(&alice.signer)) + .unwrap() + .create("Roadmap") + .unwrap(); + + // A copy of Alice's issue root that names Bob as its author, under a + // signature that doesn't verify for it. + let forged = forge(&repo.backend, *alices, *bobs); + let mut boards = Boards::open(&*repo, WriteAs::new(&bob)).unwrap(); + let card = issue_card(&*repo, forged.into()); + let board = move_card( + &alice.storage, + &mut boards, + &id, + card.clone(), + "todo", + first(), + None, + ); + assert!(board.placement(&card).is_none()); +} + +#[test] +fn operations_that_do_not_decode_are_skipped() { + let test::setup::NodeWithRepo { + node: alice, repo, .. + } = test::setup::NodeWithRepo::default(); + let issue = open_issue(&repo, &alice.signer, "Issue"); + let mut boards = Boards::open(&*repo, WriteAs::new(&alice.signer)).unwrap(); + let (id, _) = boards.create("Roadmap").unwrap(); + + let identity = repo.identity_head().unwrap(); + radicle::cob::update::( + &*repo, + &alice.signer, + Some(identity), + vec![], + alice.signer.public_key(), + Update { + changes: Contents::new(b"not an action".to_vec()), + object_id: id, + type_name: crate::TYPENAME.clone(), + message: "Garbage".into(), + embeds: vec![], + }, + ) + .unwrap(); + + let card = issue_card(&*repo, issue); + let board = move_card( + &alice.storage, + &mut boards, + &id, + card.clone(), + "todo", + first(), + None, + ); + assert_eq!(board.placement(&card).unwrap().column, "todo"); + assert_eq!(boards.get(&id).unwrap().unwrap(), board); + assert_eq!( + radicle::cob::store::ops(&id, &crate::TYPENAME, &*repo) + .unwrap() + .len(), + 3 + ); +} + +#[test] +fn closing_columns_follow_the_issue_rules() { + let test::setup::NodeWithRepo { + node: alice, repo, .. + } = test::setup::NodeWithRepo::default(); + let bob = SigningKey::mock(1); + let carol = SigningKey::mock(2); + let bobs = open_issue(&repo, &bob, "Bob's issue"); + let mut issues = Issues::open(&*repo, WriteAs::new(&alice.signer)).unwrap(); + let assignment = issues + .get_mut(&bobs, &mut NoCache) + .unwrap() + .assign([Did::from(*carol.public_key())]) + .unwrap(); + let (id, _) = Boards::open(&*repo, WriteAs::new(&alice.signer)) + .unwrap() + .create("Roadmap") + .unwrap(); + let card = issue_card(&*repo, bobs); + + // Carol is assigned, so she moves between open columns only. + let mut carols = Boards::open(&*repo, WriteAs::new(&carol)).unwrap(); + let board = move_card( + &alice.storage, + &mut carols, + &id, + card.clone(), + "todo", + first(), + Some(assignment), + ); + assert_eq!(board.placement(&card).unwrap().column, "todo"); + let board = move_card( + &alice.storage, + &mut carols, + &id, + card.clone(), + "done", + first(), + Some(assignment), + ); + assert_eq!(board.placement(&card).unwrap().column, "todo"); + + // Bob wrote the issue, so he may close and reopen it. + let mut bobs_board = Boards::open(&*repo, WriteAs::new(&bob)).unwrap(); + let board = move_card( + &alice.storage, + &mut bobs_board, + &id, + card.clone(), + "done", + first(), + None, + ); + assert_eq!(board.placement(&card).unwrap().column, "done"); + + // Within a closing column the issue's state doesn't change, so Carol may + // reorder it there, but not move it out or to a column with another + // reason. + let below = position::between(Some(&first()), None).unwrap(); + let board = move_card( + &alice.storage, + &mut carols, + &id, + card.clone(), + "done", + below.clone(), + Some(assignment), + ); + assert_eq!(board.placement(&card).unwrap().position, below); + let board = move_card( + &alice.storage, + &mut carols, + &id, + card.clone(), + "canceled", + first(), + Some(assignment), + ); + assert_eq!(board.placement(&card).unwrap().column, "done"); + let board = move_card( + &alice.storage, + &mut carols, + &id, + card.clone(), + "in-progress", + first(), + Some(assignment), + ); + assert_eq!(board.placement(&card).unwrap().column, "done"); + let board = move_card( + &alice.storage, + &mut bobs_board, + &id, + card.clone(), + "in-progress", + first(), + None, + ); + assert_eq!(board.placement(&card).unwrap().column, "in-progress"); +} + +#[test] +fn patches_never_land_in_closing_columns() { + let test::setup::NodeWithRepo { + node: alice, repo, .. + } = test::setup::NodeWithRepo::default(); + let patch = open_patch(&repo, &alice.signer, "Alice's patch"); + let mut boards = Boards::open(&*repo, WriteAs::new(&alice.signer)).unwrap(); + let (id, _) = boards.create("Roadmap").unwrap(); + let card = patch_card(&*repo, patch); + + let board = move_card( + &alice.storage, + &mut boards, + &id, + card.clone(), + "done", + first(), + None, + ); + assert!(board.placement(&card).is_none()); + let board = move_card( + &alice.storage, + &mut boards, + &id, + card.clone(), + "in-review", + first(), + None, + ); + assert_eq!(board.placement(&card).unwrap().column, "in-review"); +} + +#[test] +fn card_roots_of_the_wrong_type_are_ignored() { + let test::setup::NodeWithRepo { + node: alice, repo, .. + } = test::setup::NodeWithRepo::default(); + let (id, _) = Boards::open(&*repo, WriteAs::new(&alice.signer)) + .unwrap() + .create("Roadmap") + .unwrap(); + let bob = SigningKey::mock(1); + let issue = open_issue(&repo, &bob, "Bob's issue"); + let mut issues = Issues::open(&*repo, WriteAs::new(&bob)).unwrap(); + let comment = issues + .get_mut(&issue, &mut NoCache) + .unwrap() + .comment("Not a root", *issue, []) + .unwrap(); + let mut boards = Boards::open(&*repo, WriteAs::new(&bob)).unwrap(); + + let wrong_type = Card { + type_name: radicle::cob::patch::TYPENAME.clone(), + ..issue_card(&*repo, issue) + }; + let unknown_type = Card { + type_name: "xyz.radicle.other".parse().unwrap(), + ..issue_card(&*repo, issue) + }; + let not_a_root = issue_card(&*repo, comment.into()); + for card in [wrong_type, unknown_type, not_a_root] { + let board = move_card( + &alice.storage, + &mut boards, + &id, + card.clone(), + "todo", + first(), + None, + ); + assert!(board.placement(&card).is_none(), "{card:?}"); + } +} + +#[test] +fn delegates_cards_are_checked_too() { + let test::setup::NodeWithRepo { + node: alice, repo, .. + } = test::setup::NodeWithRepo::default(); + let bob = SigningKey::mock(1); + let alices = open_issue(&repo, &alice.signer, "Alice's issue"); + let bobs = open_issue(&repo, &bob, "Bob's issue"); + let mut issues = Issues::open(&*repo, WriteAs::new(&alice.signer)).unwrap(); + let comment = issues + .get_mut(&alices, &mut NoCache) + .unwrap() + .comment("Not a root", *alices, []) + .unwrap(); + let mut boards = Boards::open(&*repo, WriteAs::new(&alice.signer)).unwrap(); + let (id, _) = boards.create("Roadmap").unwrap(); + + let wrong_type = Card { + type_name: radicle::cob::patch::TYPENAME.clone(), + ..issue_card(&*repo, alices) + }; + let unknown_type = Card { + type_name: "xyz.radicle.other".parse().unwrap(), + ..issue_card(&*repo, alices) + }; + let not_a_root = issue_card(&*repo, comment.into()); + let forged = issue_card(&*repo, forge(&repo.backend, *alices, *bobs).into()); + for card in [wrong_type, unknown_type, not_a_root, forged] { + let board = move_card( + &alice.storage, + &mut boards, + &id, + card.clone(), + "todo", + first(), + None, + ); + assert!(board.placement(&card).is_none(), "{card:?}"); + } + + // A card from another repository can only have its type name checked. + let elsewhere = Card { + rid: other_repo(&alice, "elsewhere", Visibility::default()), + ..issue_card(&*repo, alices) + }; + let elsewhere_unknown = Card { + type_name: "xyz.radicle.other".parse().unwrap(), + ..elsewhere.clone() + }; + let board = move_card( + &alice.storage, + &mut boards, + &id, + elsewhere_unknown.clone(), + "todo", + first(), + None, + ); + assert!(board.placement(&elsewhere_unknown).is_none()); + let board = move_card( + &alice.storage, + &mut boards, + &id, + elsewhere.clone(), + "todo", + first(), + None, + ); + assert_eq!(board.placement(&elsewhere).unwrap().column, "todo"); +} + +#[test] +fn private_repositories_cards_stay_off_other_boards() { + let test::setup::NodeWithRepo { + node: alice, repo, .. + } = test::setup::NodeWithRepo::default(); + let issue = open_issue(&repo, &alice.signer, "Issue"); + let mut boards = Boards::open(&*repo, WriteAs::new(&alice.signer)).unwrap(); + let (id, _) = boards.create("Roadmap").unwrap(); + let from = |rid| Card { + rid, + ..issue_card(&*repo, issue) + }; + let private = from(other_repo(&alice, "private", Visibility::private([]))); + let missing = from("rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5".parse().unwrap()); + let public = from(other_repo(&alice, "public", Visibility::default())); + + for (card, refused) in [ + (private.clone(), crate::ShareError::Private(private.rid)), + (missing.clone(), crate::ShareError::Unavailable(missing.rid)), + ] { + let action = boards.move_action(card, "todo".into(), first(), None); + let err = boards + .update(&alice.storage, &id, vec![action]) + .unwrap_err(); + assert!( + matches!(err, crate::UpdateError::Share(ref e) if e.to_string() == refused.to_string()), + "{err}" + ); + } + assert!(boards.get(&id).unwrap().unwrap().cards().is_empty()); + + let board = move_card( + &alice.storage, + &mut boards, + &id, + public.clone(), + "todo", + first(), + None, + ); + assert_eq!(board.placement(&public).unwrap().column, "todo"); +} + +#[test] +fn oversized_names_columns_and_positions_are_ignored() { + let test::setup::NodeWithRepo { + node: alice, repo, .. + } = test::setup::NodeWithRepo::default(); + let issue = open_issue(&repo, &alice.signer, "Issue"); + let mut boards = Boards::open(&*repo, WriteAs::new(&alice.signer)).unwrap(); + let (id, _) = boards.create("Roadmap").unwrap(); + let card = issue_card(&*repo, issue); + + let board = boards + .rename(&id, &"n".repeat(crate::MAX_NAME_LEN + 1)) + .unwrap(); + assert_eq!(board.name(), "Roadmap"); + let board = boards + .rename(&id, &"n".repeat(crate::MAX_NAME_LEN)) + .unwrap(); + assert_eq!(board.name().len(), crate::MAX_NAME_LEN); + + let too_many: Vec<_> = (0..=crate::MAX_COLUMNS) + .map(|i| Column::new(&format!("c{i}"), "Column", None)) + .collect(); + let long_id = vec![Column::new( + &"i".repeat(crate::MAX_COLUMN_ID_LEN + 1), + "Column", + None, + )]; + let long_name = vec![Column::new("c", &"n".repeat(crate::MAX_NAME_LEN + 1), None)]; + for columns in [too_many, long_id, long_name] { + let board = boards.set_columns(&id, columns).unwrap(); + assert_eq!(board.columns(), crate::default_columns().as_slice()); + } + + let long = format!("{}1", "V".repeat(crate::MAX_POSITION_LEN)); + let board = move_card( + &alice.storage, + &mut boards, + &id, + card.clone(), + "todo", + long, + None, + ); + assert!(board.placement(&card).is_none()); +} + +#[test] +fn moves_that_do_not_link_their_local_card_are_ignored() { + let test::setup::NodeWithRepo { + node: alice, repo, .. + } = test::setup::NodeWithRepo::default(); + let issue = open_issue(&repo, &alice.signer, "Alice's issue"); + let mut boards = Boards::open(&*repo, WriteAs::new(&alice.signer)).unwrap(); + let (id, _) = boards.create("Roadmap").unwrap(); + let card = issue_card(&*repo, issue); + + let unlinked = Action::Move { + card: card.clone(), + column: "todo".into(), + position: first(), + proof: None, + local: false, + }; + let board = boards.update(&alice.storage, &id, vec![unlinked]).unwrap(); + assert!(board.placement(&card).is_none()); + + let linked = boards.move_action(card.clone(), "todo".into(), first(), None); + let board = boards.update(&alice.storage, &id, vec![linked]).unwrap(); + assert_eq!(board.placement(&card).unwrap().column, "todo"); +} + +/// A copy of the operation `op` without `parent` among its git parents, and +/// with the same message, so the same trailers. +fn without_parent(repo: &raw::Repository, op: Oid, parent: Oid) -> Oid { + let genuine = repo.find_commit(op.into()).unwrap(); + let signature = genuine.header_field_bytes("gpgsig").unwrap(); + let parents: Vec<_> = genuine + .parents() + .filter(|p| Oid::from(p.id()) != parent) + .collect(); + let parents: Vec<_> = parents.iter().collect(); + let buffer = repo + .commit_create_buffer( + &genuine.author(), + &genuine.committer(), + genuine.message().unwrap(), + &genuine.tree().unwrap(), + &parents, + ) + .unwrap(); + repo.commit_signed( + buffer.as_str().unwrap(), + signature.as_str().unwrap(), + Some("gpgsig"), + ) + .unwrap() + .into() +} + +/// Replaces the tip of `alice`'s reference to board `id` with a copy of it +/// that lacks `parent`, and returns the copy's entry. +fn drop_parent( + alice: &test::setup::Node, + repo: &test::setup::NodeRepo, + id: &BoardId, + parent: Oid, +) -> radicle::cob::Entry { + let name = radicle::git::refs::storage::cob(alice.signer.public_key(), &crate::TYPENAME, id) + .to_string(); + let tip = repo.backend.refname_to_id(&name).unwrap(); + let copy = without_parent(&repo.backend, tip.into(), parent); + repo.backend + .reference(&name, copy.into(), true, "") + .unwrap(); + radicle::cob::change::Storage::load(&**repo, copy).unwrap() +} + +// The commits an op relies on must be its git parents, not just named in +// trailers, so every peer evaluates it the same way. +#[test] +fn moves_whose_card_is_not_a_parent_are_ignored() { + let test::setup::NodeWithRepo { + node: alice, repo, .. + } = test::setup::NodeWithRepo::default(); + let issue = open_issue(&repo, &alice.signer, "Alice's issue"); + let mut boards = Boards::open(&*repo, WriteAs::new(&alice.signer)).unwrap(); + let (id, _) = boards.create("Roadmap").unwrap(); + let card = issue_card(&*repo, issue); + let board = move_card( + &alice.storage, + &mut boards, + &id, + card.clone(), + "todo", + first(), + None, + ); + assert_eq!(board.placement(&card).unwrap().column, "todo"); + + // The same move, with the card's root in a trailer but not a parent. + let entry = drop_parent(&alice, &repo, &id, *issue); + assert!(entry.related.contains(&*issue)); + let board = boards.get(&id).unwrap().unwrap(); + assert!(board.placement(&card).is_none()); +} + +#[test] +fn operations_whose_identity_is_not_a_parent_are_ignored() { + let test::setup::NodeWithRepo { + node: alice, repo, .. + } = test::setup::NodeWithRepo::default(); + let mut boards = Boards::open(&*repo, WriteAs::new(&alice.signer)).unwrap(); + let (id, _) = boards.create("Roadmap").unwrap(); + assert_eq!(boards.rename(&id, "Plan").unwrap().name(), "Plan"); + + // The same rename, with the identity in a trailer but not a parent. + let identity = repo.identity_head().unwrap(); + let entry = drop_parent(&alice, &repo, &id, identity); + assert_eq!(entry.resource, Some(identity)); + assert_eq!(boards.get(&id).unwrap().unwrap().name(), "Roadmap"); +} + +#[test] +fn boards_whose_first_identity_is_not_a_parent_are_ignored() { + let test::setup::NodeWithRepo { + node: alice, repo, .. + } = test::setup::NodeWithRepo::default(); + let mut boards = Boards::open(&*repo, WriteAs::new(&alice.signer)).unwrap(); + let (id, _) = boards.create("Roadmap").unwrap(); + + // The same first op, with the identity in a trailer but not a parent. + let identity = repo.identity_head().unwrap(); + let copy = without_parent(&repo.backend, *id, identity); + let entry = radicle::cob::change::Storage::load(&*repo, copy).unwrap(); + assert_eq!(entry.resource, Some(identity)); + let copy = BoardId::from(copy); + let name = radicle::git::refs::storage::cob(alice.signer.public_key(), &crate::TYPENAME, ©) + .to_string(); + repo.backend + .reference(&name, (*copy).into(), true, "") + .unwrap(); + + let read = Boards::open(&*repo, ReadOnly).unwrap(); + assert!(read.get(&id).unwrap().is_some()); + assert!(!matches!(read.get(©), Ok(Some(_)))); + assert!( + read.list() + .unwrap() + .iter() + .all(|(listed, _)| *listed != copy) + ); +} + +#[test] +fn proofs_whose_identity_is_not_a_parent_are_ignored() { + let test::setup::NodeWithRepo { + node: alice, repo, .. + } = test::setup::NodeWithRepo::default(); + let bob = SigningKey::mock(1); + let issue = open_issue(&repo, &alice.signer, "Alice's issue"); + let assignment = Issues::open(&*repo, WriteAs::new(&alice.signer)) + .unwrap() + .get_mut(&issue, &mut NoCache) + .unwrap() + .assign([Did::from(*bob.public_key())]) + .unwrap(); + let (id, _) = Boards::open(&*repo, WriteAs::new(&alice.signer)) + .unwrap() + .create("Roadmap") + .unwrap(); + let card = issue_card(&*repo, issue); + + // The same assignment, with the identity in a trailer but not a parent. + let identity = radicle::cob::change::Storage::load(&*repo, assignment) + .unwrap() + .resource + .unwrap(); + let copy = without_parent(&repo.backend, assignment, identity); + + let mut boards = Boards::open(&*repo, WriteAs::new(&bob)).unwrap(); + let board = move_card( + &alice.storage, + &mut boards, + &id, + card.clone(), + "todo", + first(), + Some(copy), + ); + assert!(board.placement(&card).is_none()); + + let board = move_card( + &alice.storage, + &mut boards, + &id, + card.clone(), + "todo", + first(), + Some(assignment), + ); + assert_eq!(board.placement(&card).unwrap().column, "todo"); +} diff --git a/desktop/crates/radicle-board/tests/fixtures/positions.json b/desktop/crates/radicle-board/tests/fixtures/positions.json new file mode 100644 index 000000000..cd8667b16 --- /dev/null +++ b/desktop/crates/radicle-board/tests/fixtures/positions.json @@ -0,0 +1,223 @@ +{ + "between": [ + { + "before": null, + "after": null, + "key": "V" + }, + { + "before": "V", + "after": null, + "key": "k" + }, + { + "before": null, + "after": "V", + "key": "F" + }, + { + "before": "V", + "after": "W", + "key": "VV" + }, + { + "before": "A", + "after": "C", + "key": "B" + }, + { + "before": "k", + "after": "l", + "key": "kV" + }, + { + "before": "V", + "after": "V1", + "key": "V0V" + }, + { + "before": "Vz", + "after": "W", + "key": "VzV" + }, + { + "before": "zz", + "after": null, + "key": "zzV" + }, + { + "before": "1", + "after": null, + "key": "V" + }, + { + "before": null, + "after": "1", + "key": "0V" + }, + { + "before": "AB", + "after": "AC", + "key": "ABV" + }, + { + "before": "A", + "after": "AV", + "key": "AF" + }, + { + "before": "V00000000000000000000000000000000000000000000000000000000000001", + "after": "V00000000000000000000000000000000000000000000000000000000000002", + "key": "V00000000000000000000000000000000000000000000000000000000000001V" + }, + { + "before": "W", + "after": "V", + "key": null + }, + { + "before": "V", + "after": "V", + "key": null + }, + { + "before": "V0", + "after": null, + "key": null + }, + { + "before": "!", + "after": null, + "key": null + }, + { + "before": "", + "after": null, + "key": null + }, + { + "before": null, + "after": "", + "key": null + }, + { + "before": "V000000000000000000000000000000000000000000000000000000000000001", + "after": "V000000000000000000000000000000000000000000000000000000000000002", + "key": null + }, + { + "before": "V000000000000000000000000000000000000000000000000000000000000001", + "after": null, + "key": "k" + } + ], + "neighbours": [ + { + "positions": [], + "index": 0, + "before": null, + "after": null, + "key": "V" + }, + { + "positions": [ + "A", + "C", + "E" + ], + "index": 0, + "before": null, + "after": "A", + "key": "5" + }, + { + "positions": [ + "A", + "C", + "E" + ], + "index": 1, + "before": "A", + "after": "C", + "key": "B" + }, + { + "positions": [ + "A", + "C", + "E" + ], + "index": 3, + "before": "E", + "after": null, + "key": "c" + }, + { + "positions": [ + "A", + "A" + ], + "index": 0, + "before": null, + "after": "A", + "key": "5" + }, + { + "positions": [ + "A", + "C", + "C", + "E" + ], + "index": 1, + "before": "A", + "after": "C", + "key": "B" + }, + { + "positions": [ + "A", + "C", + "C", + "E" + ], + "index": 2, + "before": "C", + "after": "E", + "key": "D" + }, + { + "positions": [ + "A", + "B", + "B", + "C" + ], + "index": 2, + "before": "B", + "after": "C", + "key": "BV" + }, + { + "positions": [ + "C", + "C" + ], + "index": 1, + "before": "C", + "after": null, + "key": "b" + }, + { + "positions": [ + "C", + "C", + "C" + ], + "index": 1, + "before": "C", + "after": null, + "key": "b" + } + ] +} diff --git a/desktop/docs/adr/0005-boards.md b/desktop/docs/adr/0005-boards.md new file mode 100644 index 000000000..85a1c1512 --- /dev/null +++ b/desktop/docs/adr/0005-boards.md @@ -0,0 +1,126 @@ +# 5. Boards on a custom COB + +Date: 2026-10-02 + +## Status + +Accepted + +## Context + +We want boards in the app: a repository's issues and patches arranged in +columns, with everyone seeing the same board. + +## Decision + +**A board is a custom COB in a Radicle repository.** It stores only placement: +its columns, and which column each card is in and in what order. Titles, state +and labels come from the cards. Only delegates create boards: a board whose +first op isn't signed by a delegate fails to load on every peer, and the app +creates one only when a delegate asks and confirms. + +**Cards can come from any repository.** The one exception is private +repositories: their cards only go on their own boards, since a move writes the +card's repository and id into the board's history for everyone who replicates +it. `radicle-board` refuses to write such a move, and also one whose repository +it doesn't have. The evaluator can't check this, so clients that write ops some +other way aren't bound by it. Cards are keyed by `{ rid, type, oid }`, as in ADR +0003, where `oid` is the issue's or patch's id. A card from a repository we +don't have shows as unresolved. + +**Concurrent edits converge.** A position is a string of base-62 digits compared +like the digits of a fraction, so there is always room between two keys (between +`a` and `b` comes `aV`) and a move never rewrites other cards; equal keys sort +by card. Heartwood applies ops in causal order: an op always comes after the ops +it was built on. Only concurrent ops are ordered by the writer's timestamp, then +op id (by op id alone for ops built directly on the board's first op). When two +people move the same card at the same time, the move that comes last wins. A +skewed clock can decide that race, but it can't undo a move its writer had +already seen. + +**Delegates change boards; others move their own cards.** As in heartwood's +issues, delegates may do anything. A non-delegate may move a card from the +board's own repository in two cases: they wrote its issue or patch, or a +delegate assigned them to the issue. In the second case, the move names that +assignment op as proof. Any past assignment counts, so other clients can still +use it after the person is unassigned. This app only lets current assignees move +cards. Each board op is a git commit. A move also lists the commits it relies on +as extra parents: the issue's or patch's first op, and the assignment op if +there is one. Fetching the board then fetches those too, so every peer can check +the move, even before it has fetched the issue itself. A move whose commits +aren't among its parents is ignored, and the evaluator verifies their +signatures. Which columns a card may move between is covered under closing +columns below. + +**Only issues and patches can be cards**, for delegates too. For a card in the +board's own repository, the evaluator also checks that the issue or patch exists +and really is that type, so a patch can't pass as an issue. Cards from other +repositories aren't part of the board's history, so only their type name is +checked. + +**Bad actions are ignored, not rejected.** Unlike issues and patches, the +evaluator skips bad actions instead of failing their op. Heartwood drops a +failed op together with every op built on it. A move can be valid when written +and become invalid once a concurrent op is ordered before it, after others have +built on it. And one op carries a whole batch of changes. Skipping drops only +the bad action. It skips actions that fail these checks, exceed the limits +(names of 100 bytes, 50 columns, column ids of 64 bytes, positions of 64 bytes) +or repeat a column id, and ops that don't decode or lack an identity. Only a bad +first op fails, which hides that board. A move into a column the board doesn't +have still counts and closes nothing. The card shows as unplaced until the +column exists, and a delegate's or author's move doesn't depend on whether it's +ordered before or after a change that adds or removes the column. + +**Columns can close issues, under the issue's own rules.** A column may close +issues as solved or as other; a new board's Done and Canceled columns do. The +evaluator lets only a delegate or the issue's author move an issue into or out +of a closing column, which closes or reopens it; assignees move cards only +between columns that do the same thing to issues, such as within Done, and +patches never enter a closing one. A move that doesn't cross between open and +closing columns never changes an issue's state. + +**A card's state and its placement are stored apart, and the state decides where +it shows.** An issue's state (open, or closed as solved or as other) and a +patch's state (draft, open, merged or archived) live in the issue or patch +itself. They change by its own rules, from this app, the CLI or any other +client. A card's placement, its column and position, lives in the board. The two +are written and replicated separately, so they can disagree: an issue placed in +In Progress may have been closed from the CLI, or a patch placed in In Review +may have been merged. Moving a card writes only its placement, unless the move +crosses between open and closing columns, which also closes or reopens the +issue. When the two disagree, the issue's or patch's state wins over the +placement. The placement is used only while it agrees with the state, and +nothing corrects it, so a card goes back to its placed column once its state +agrees again: + +- A closed issue shows where it's placed if that column closes issues for the + same reason. Otherwise it goes to the first column for its reason, then to any + closing column, and with no closing column to the first column with a "closed" + badge. +- An open issue shows where it's placed if that column keeps issues open. + Otherwise it goes to the first open column, or, if it isn't placed at all, to + In Review when it has an open linked patch and In Progress when it has a draft + one. +- A merged patch shows in the first column for solved and an archived one in the + first column for other, then in any closing column, and with no closing column + in the first column. A draft or open patch shows where it's placed, or in the + first open column. Patches are never placed in closing columns. + +The app refuses drops that wouldn't show where dropped. + +**Patches link to issues with a keyword and a full reference**: `Fixes`, +`Closes` or `Resolves` (or their imperatives, any case, optional colon) before +`rad:/cob/xyz.radicle.issue/`, bare or as a Markdown link's target, in +the title or first description. A linked patch shows as a chip on the issue's +card and never changes its state. Priority is the label `priority:`, so +other tools can read it. + +**The COB lives in its own crate**, `radicle-board`, depending only on +`radicle`. The type name is `works.monadic.board`; the op manifest carries the +format version, and ops in a newer format are ignored. + +**Changes are batched.** The app waits until nothing has changed for 2.5 seconds +and writes all pending changes as one op, dropping changes that cancel out. +Pending changes are written when leaving or switching boards, and on quitting, +which writes without announcing: peers learn of those changes with the +repository's next announcement or when the node restarts.