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.