mirror of
https://seed.radicle.dev/rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5.git
synced 2026-11-15 11:20:36 +08:00
desktop: Add the board COB
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ņš <rudolfs@osins.org>
This commit is contained in:
co-authored by
Rūdolfs Ošiņš
parent
787be14d73
commit
3f32a750b8
@@ -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
|
||||
|
||||
Generated
+11
@@ -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"
|
||||
|
||||
@@ -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"] }
|
||||
@@ -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<TypeName> =
|
||||
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<Closes>,
|
||||
}
|
||||
|
||||
impl Column {
|
||||
pub fn new(id: &str, name: &str, closes: Option<Closes>) -> Self {
|
||||
Self {
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
closes,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn default_columns() -> Vec<Column> {
|
||||
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<Column> },
|
||||
#[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<Oid>,
|
||||
/// 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<Oid> {
|
||||
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<Column>,
|
||||
cards: BTreeMap<Card, Placement>,
|
||||
}
|
||||
|
||||
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<Closes> {
|
||||
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<R>(
|
||||
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<R: ReadRepository>(op: Oid, identity: Option<Oid>, repo: &R) -> Option<Oid> {
|
||||
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<R>(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::<cob::issue::Action>::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<R: ReadRepository>(op: Op<Action>, repo: &R) -> Result<Self, Error> {
|
||||
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<Item = &'a cob::Entry>>(
|
||||
&mut self,
|
||||
_op: Op<Action>,
|
||||
_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<R> Evaluate<R> 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<Self, Error> {
|
||||
// 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)?;
|
||||
<Board as store::Cob>::from_root(op, repo)
|
||||
}
|
||||
|
||||
fn apply<'a, I: Iterator<Item = (&'a cob::EntryId, &'a cob::Entry)>>(
|
||||
&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::<Action>::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<Namespace = NodeId>,
|
||||
A: access::Access + 'a,
|
||||
{
|
||||
pub fn open(repository: &'a R, access: A) -> Result<Self, RepositoryError> {
|
||||
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<S: ReadStorage>(&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<Namespace = NodeId>
|
||||
+ cob::change::Storage<
|
||||
ObjectId = Oid,
|
||||
Parent = Oid,
|
||||
PublicKey = crypto::PublicKey,
|
||||
Signature = crypto::Signature,
|
||||
>,
|
||||
A: access::Access + 'a,
|
||||
{
|
||||
pub fn get(&self, id: &BoardId) -> Result<Option<Board>, store::Error> {
|
||||
self.raw.get(id)
|
||||
}
|
||||
|
||||
pub fn list(&self) -> Result<Vec<(BoardId, Board)>, 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<bool, RepositoryError> {
|
||||
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<Oid>,
|
||||
) -> 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<Namespace = NodeId>
|
||||
+ 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<Board, store::Error> {
|
||||
self.transact(
|
||||
id,
|
||||
"Rename board",
|
||||
vec![Action::Edit {
|
||||
name: name.to_string(),
|
||||
}],
|
||||
)
|
||||
}
|
||||
|
||||
pub fn set_columns(
|
||||
&mut self,
|
||||
id: &BoardId,
|
||||
columns: Vec<Column>,
|
||||
) -> Result<Board, store::Error> {
|
||||
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<T: ReadStorage>(
|
||||
&mut self,
|
||||
storage: &T,
|
||||
id: &BoardId,
|
||||
actions: Vec<Action>,
|
||||
) -> Result<Board, UpdateError> {
|
||||
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<Action>,
|
||||
) -> Result<Board, store::Error> {
|
||||
let mut tx = store::Transaction::<Board, R>::default();
|
||||
tx.extend(actions)?;
|
||||
let (board, _) = tx.commit(message, *id, &mut self.raw)?;
|
||||
|
||||
Ok(board)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod test;
|
||||
@@ -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<usize> {
|
||||
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<String> {
|
||||
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<Between>,
|
||||
neighbours: Vec<Neighbours>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Between {
|
||||
before: Option<String>,
|
||||
after: Option<String>,
|
||||
key: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Neighbours {
|
||||
positions: Vec<String>,
|
||||
index: usize,
|
||||
before: Option<String>,
|
||||
after: Option<String>,
|
||||
key: Option<String>,
|
||||
}
|
||||
|
||||
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)));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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:<rid>/cob/xyz.radicle.issue/<oid>`, 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:<level>`, 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.
|
||||
Reference in New Issue
Block a user