Radish alpha
h
rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
Radicle Heartwood Protocol & Stack
Radicle
Git
cli: Migrate `rad issue` to `clap`
Merged did:key:z6MkgFq6...nBGz opened 7 months ago

This is a split-off of rad patch show 0866819, that only migrates rad issue.

13 files changed +1052 -705 22720e71 c7bff284
modified CHANGELOG.md
@@ -29,6 +29,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `git-remote-rad` now correctly reports the default branch to Git by listing
  the symbolic reference `HEAD`.
- `rad status` learned a new option `--only nid` for printing the Node ID.
+
- `rad issue` now uses `clap` to parse its command-line arguments.
+
  This affects error reporting as well as help output.

## Fixed Bugs

modified Cargo.lock
@@ -150,9 +150,9 @@ dependencies = [

[[package]]
name = "anstyle"
-
version = "1.0.6"
+
version = "1.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "8901269c6307e8d93993578286ac0edf7f195079ffff5ebdeea6a59ffb7e36bc"
+
checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd"

[[package]]
name = "anstyle-parse"
@@ -462,6 +462,46 @@ dependencies = [
]

[[package]]
+
name = "clap"
+
version = "4.5.44"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "1c1f056bae57e3e54c3375c41ff79619ddd13460a17d7438712bd0d83fda4ff8"
+
dependencies = [
+
 "clap_builder",
+
 "clap_derive",
+
]
+

+
[[package]]
+
name = "clap_builder"
+
version = "4.5.44"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "b3e7f4214277f3c7aa526a59dd3fbe306a370daee1f8b7b8c987069cd8e888a8"
+
dependencies = [
+
 "anstream",
+
 "anstyle",
+
 "clap_lex",
+
 "strsim",
+
]
+

+
[[package]]
+
name = "clap_derive"
+
version = "4.5.41"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "ef4f52386a59ca4c860f7393bcf8abd8dfd91ecccc0f774635ff68e92eeef491"
+
dependencies = [
+
 "heck",
+
 "proc-macro2",
+
 "quote",
+
 "syn 2.0.89",
+
]
+

+
[[package]]
+
name = "clap_lex"
+
version = "0.7.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675"
+

+
[[package]]
name = "colorchoice"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1635,6 +1675,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604"

[[package]]
+
name = "heck"
+
version = "0.5.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+

+
[[package]]
name = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2767,6 +2813,7 @@ version = "0.16.0"
dependencies = [
 "anyhow",
 "chrono",
+
 "clap",
 "dunce",
 "git-ref-format",
 "human-panic",
@@ -3704,6 +3751,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520"

[[package]]
+
name = "strsim"
+
version = "0.11.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+

+
[[package]]
name = "structured-logger"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
modified crates/radicle-cli/Cargo.toml
@@ -16,6 +16,7 @@ path = "src/main.rs"
[dependencies]
anyhow = "1"
chrono = { workspace = true, features = ["clock", "std"] }
+
clap = { version = "4.5.44", features = ["derive"] }
dunce = { workspace = true }
git-ref-format = { version = "0.3.0", features = ["macro"] }
human-panic.workspace = true
added crates/radicle-cli/examples/rad-help.md
@@ -0,0 +1,42 @@
+
```
+
$ rad --help
+
rad [..]
+
Radicle command line interface
+

+
Usage: rad <command> [--help]
+
Common `rad` commands used in various situations:
+

+
	auth         Manage identities and profiles
+
	block        Block repositories or nodes from being seeded or followed
+
	checkout     Checkout a repository into the local directory
+
	clone        Clone a Radicle repository
+
	config       Manage your local Radicle configuration
+
	fork         Create a fork of a repository
+
	help         CLI help
+
	id           Manage repository identities
+
	init         Initialize a Radicle repository
+
	inbox        Manage your Radicle notifications
+
	inspect      Inspect a Radicle repository
+
	issue        Manage issues
+
	ls           List repositories
+
	node         Control and query the Radicle Node
+
	patch        Manage patches
+
	path         Display the Radicle home path
+
	clean        Remove all remotes from a repository
+
	self         Show information about your identity and device
+
	seed         Manage repository seeding policies
+
	follow       Manage node follow policies
+
	unblock      Unblock repositories or nodes to allow them to be seeded or followed
+
	unfollow     Unfollow a peer
+
	unseed       Remove repository seeding policies
+
	remote       Manage a repository's remotes
+
	stats        Displays aggregated repository and node metrics
+
	sync         Sync repositories to the network
+

+
See `rad <command> --help` to learn about a specific command.
+

+
Do you have feedback?
+
 - Chat[..]
+
 - Mail[..]
+
   (Messages are automatically posted to the public #feedback channel on Zulip.)
+
```
added crates/radicle-cli/examples/rad-issue-list.md
@@ -0,0 +1,62 @@
+
Let's say we have a project with an issue created already. We can list all open issues.
+

+
```
+
$ rad issue list
+
╭────────────────────────────────────────────────────────────────────────────────────────────────────╮
+
│ ●   ID        Title                         Author           Labels             Assignees   Opened │
+
├────────────────────────────────────────────────────────────────────────────────────────────────────┤
+
│ ●   d87dcfe   flux capacitor underpowered   alice    (you)   good-first-issue               now    │
+
╰────────────────────────────────────────────────────────────────────────────────────────────────────╯
+
```
+

+
We can now assign ourselves to the open issue.
+

+
```
+
$ rad issue assign d87dcfe --add did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi --no-announce
+
```
+

+
It will now also show up in the list of issues assigned to us.
+

+
```
+
$ rad issue list --assigned me
+
╭────────────────────────────────────────────────────────────────────────────────────────────────────╮
+
│ ●   ID        Title                         Author           Labels             Assignees   Opened │
+
├────────────────────────────────────────────────────────────────────────────────────────────────────┤
+
│ ●   d87dcfe   flux capacitor underpowered   alice    (you)   good-first-issue   alice       now    │
+
╰────────────────────────────────────────────────────────────────────────────────────────────────────╯
+
```
+

+
If we now fix this issue, we can close it.
+

+
```
+
$ rad issue state --solved d87dcfe --no-announce
+
✓ Issue d87dcfe is now solved
+
```
+

+
It will not show up in the list of open issues anymore.
+

+
```
+
$ rad issue list
+
```
+

+
Instead, it will now show up in the list of solved issues.
+

+
```
+
$ rad issue list --solved
+
╭────────────────────────────────────────────────────────────────────────────────────────────────────╮
+
│ ●   ID        Title                         Author           Labels             Assignees   Opened │
+
├────────────────────────────────────────────────────────────────────────────────────────────────────┤
+
│ ●   d87dcfe   flux capacitor underpowered   alice    (you)   good-first-issue   alice       now    │
+
╰────────────────────────────────────────────────────────────────────────────────────────────────────╯
+
```
+

+
Note: You can achieve the same by omitting the `list` subcommand, since that's the fallback when no subcommand is specified.
+

+
```
+
$ rad issue --solved
+
╭────────────────────────────────────────────────────────────────────────────────────────────────────╮
+
│ ●   ID        Title                         Author           Labels             Assignees   Opened │
+
├────────────────────────────────────────────────────────────────────────────────────────────────────┤
+
│ ●   d87dcfe   flux capacitor underpowered   alice    (you)   good-first-issue   alice       now    │
+
╰────────────────────────────────────────────────────────────────────────────────────────────────────╯
+
```
modified crates/radicle-cli/src/commands/help.rs
@@ -10,33 +10,62 @@ pub const HELP: Help = Help {
    usage: "Usage: rad help [--help]",
};

-
const COMMANDS: &[Help] = &[
-
    crate::commands::auth::HELP,
-
    crate::commands::block::HELP,
-
    crate::commands::checkout::HELP,
-
    crate::commands::clone::HELP,
-
    crate::commands::config::HELP,
-
    crate::commands::fork::HELP,
-
    crate::commands::help::HELP,
-
    crate::commands::id::HELP,
-
    crate::commands::init::HELP,
-
    crate::commands::inbox::HELP,
-
    crate::commands::inspect::HELP,
-
    crate::commands::issue::HELP,
-
    crate::commands::ls::HELP,
-
    crate::commands::node::HELP,
-
    crate::commands::patch::HELP,
-
    crate::commands::path::HELP,
-
    crate::commands::clean::HELP,
-
    crate::commands::rad_self::HELP,
-
    crate::commands::seed::HELP,
-
    crate::commands::follow::HELP,
-
    crate::commands::unblock::HELP,
-
    crate::commands::unfollow::HELP,
-
    crate::commands::unseed::HELP,
-
    crate::commands::remote::HELP,
-
    crate::commands::stats::HELP,
-
    crate::commands::sync::HELP,
+
enum CommandItem {
+
    Lexopt(Help),
+
    Clap {
+
        name: &'static str,
+
        about: &'static str,
+
    },
+
}
+

+
impl CommandItem {
+
    fn name(&self) -> &str {
+
        match self {
+
            CommandItem::Lexopt(help) => help.name,
+
            CommandItem::Clap { name, .. } => name,
+
        }
+
    }
+

+
    fn description(&self) -> &str {
+
        match self {
+
            CommandItem::Lexopt(help) => help.description,
+
            CommandItem::Clap {
+
                about: description, ..
+
            } => description,
+
        }
+
    }
+
}
+

+
const COMMANDS: &[CommandItem] = &[
+
    CommandItem::Lexopt(crate::commands::auth::HELP),
+
    CommandItem::Lexopt(crate::commands::block::HELP),
+
    CommandItem::Lexopt(crate::commands::checkout::HELP),
+
    CommandItem::Lexopt(crate::commands::clone::HELP),
+
    CommandItem::Lexopt(crate::commands::config::HELP),
+
    CommandItem::Lexopt(crate::commands::fork::HELP),
+
    CommandItem::Lexopt(crate::commands::help::HELP),
+
    CommandItem::Lexopt(crate::commands::id::HELP),
+
    CommandItem::Lexopt(crate::commands::init::HELP),
+
    CommandItem::Lexopt(crate::commands::inbox::HELP),
+
    CommandItem::Lexopt(crate::commands::inspect::HELP),
+
    CommandItem::Clap {
+
        name: "issue",
+
        about: crate::commands::issue::ABOUT,
+
    },
+
    CommandItem::Lexopt(crate::commands::ls::HELP),
+
    CommandItem::Lexopt(crate::commands::node::HELP),
+
    CommandItem::Lexopt(crate::commands::patch::HELP),
+
    CommandItem::Lexopt(crate::commands::path::HELP),
+
    CommandItem::Lexopt(crate::commands::clean::HELP),
+
    CommandItem::Lexopt(crate::commands::rad_self::HELP),
+
    CommandItem::Lexopt(crate::commands::seed::HELP),
+
    CommandItem::Lexopt(crate::commands::follow::HELP),
+
    CommandItem::Lexopt(crate::commands::unblock::HELP),
+
    CommandItem::Lexopt(crate::commands::unfollow::HELP),
+
    CommandItem::Lexopt(crate::commands::unseed::HELP),
+
    CommandItem::Lexopt(crate::commands::remote::HELP),
+
    CommandItem::Lexopt(crate::commands::stats::HELP),
+
    CommandItem::Lexopt(crate::commands::sync::HELP),
];

#[derive(Default)]
@@ -79,8 +108,8 @@ pub fn run(_options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
    for help in COMMANDS {
        term::info!(
            "\t{} {}",
-
            term::format::bold(format!("{:-12}", help.name)),
-
            term::format::dim(help.description)
+
            term::format::bold(format!("{:-12}", help.name())),
+
            term::format::dim(help.description())
        );
    }
    term::blank();
modified crates/radicle-cli/src/commands/issue.rs
@@ -1,582 +1,124 @@
+
mod args;
mod cache;
+
mod comment;

-
use std::collections::BTreeSet;
-
use std::ffi::OsString;
-
use std::str::FromStr;
+
use anyhow::Context as _;

-
use anyhow::{anyhow, Context as _};
-

-
use radicle::cob::common::{Label, Reaction};
+
use radicle::cob::common::Label;
use radicle::cob::issue::{CloseReason, State};
-
use radicle::cob::{issue, thread, Title};
+
use radicle::cob::{issue, Title};
+

use radicle::crypto;
-
use radicle::git::Oid;
use radicle::issue::cache::Issues as _;
use radicle::node::device::Device;
use radicle::node::NodeId;
-
use radicle::prelude::{Did, RepoId};
+
use radicle::prelude::Did;
use radicle::profile;
use radicle::storage;
-
use radicle::storage::{ReadRepository, WriteRepository, WriteStorage};
+
use radicle::storage::{WriteRepository, WriteStorage};
use radicle::Profile;
use radicle::{cob, Node};

+
pub use args::Args;
+
use args::{Assigned, Command, CommentAction, StateArg};
+

use crate::git::Rev;
use crate::node;
use crate::terminal as term;
-
use crate::terminal::args::{Args, Error, Help};
+
use crate::terminal::args::Error;
use crate::terminal::format::Author;
use crate::terminal::issue::Format;
-
use crate::terminal::patch::Message;
use crate::terminal::Element;

-
pub const HELP: Help = Help {
-
    name: "issue",
-
    description: "Manage issues",
-
    version: env!("RADICLE_VERSION"),
-
    usage: r#"
-
Usage
-

-
    rad issue [<option>...]
-
    rad issue delete <issue-id> [<option>...]
-
    rad issue edit <issue-id> [--title <title>] [--description <text>] [<option>...]
-
    rad issue list [--assigned <did>] [--all | --closed | --open | --solved] [<option>...]
-
    rad issue open [--title <title>] [--description <text>] [--label <label>] [<option>...]
-
    rad issue react <issue-id> [--emoji <char>] [--to <comment>] [<option>...]
-
    rad issue assign <issue-id> [--add <did>] [--delete <did>] [<option>...]
-
    rad issue label <issue-id> [--add <label>] [--delete <label>] [<option>...]
-
    rad issue comment <issue-id> [--message <message>] [--reply-to <comment-id>] [--edit <comment-id>] [<option>...]
-
    rad issue show <issue-id> [<option>...]
-
    rad issue state <issue-id> [--closed | --open | --solved] [<option>...]
-
    rad issue cache [<issue-id>] [--storage] [<option>...]
-

-
Assign options
-

-
    -a, --add    <did>     Add an assignee to the issue (may be specified multiple times).
-
    -d, --delete <did>     Delete an assignee from the issue (may be specified multiple times).
-

-
    Note: --add takes precedence over --delete
-

-
Label options
-

-
    -a, --add    <label>   Add a label to the issue (may be specified multiple times).
-
    -d, --delete <label>   Delete a label from the issue (may be specified multiple times).
-

-
    Note: --add takes precedence over --delete
-

-
Show options
-

-
    -v, --verbose          Show additional information about the issue
-

-
Options
-

-
        --repo <rid>       Operate on the given repository (default: cwd)
-
        --no-announce      Don't announce issue to peers
-
        --header           Show only the issue header, hiding the comments
-
    -q, --quiet            Don't print anything
-
        --help             Print help
-
"#,
-
};
-

-
#[derive(Default, Debug, PartialEq, Eq)]
-
pub enum OperationName {
-
    Assign,
-
    Edit,
-
    Open,
-
    Comment,
-
    Delete,
-
    Label,
-
    #[default]
-
    List,
-
    React,
-
    Show,
-
    State,
-
    Cache,
-
}
-

-
/// Command line Peer argument.
-
#[derive(Default, Debug, PartialEq, Eq)]
-
pub enum Assigned {
-
    #[default]
-
    Me,
-
    Peer(Did),
-
}
-

-
#[derive(Debug, PartialEq, Eq)]
-
pub enum Operation {
-
    Edit {
-
        id: Rev,
-
        title: Option<Title>,
-
        description: Option<String>,
-
    },
-
    Open {
-
        title: Option<Title>,
-
        description: Option<String>,
-
        labels: Vec<Label>,
-
        assignees: Vec<Did>,
-
    },
-
    Show {
-
        id: Rev,
-
        format: Format,
-
        verbose: bool,
-
    },
-
    CommentEdit {
-
        id: Rev,
-
        comment_id: Rev,
-
        message: Message,
-
    },
-
    Comment {
-
        id: Rev,
-
        message: Message,
-
        reply_to: Option<Rev>,
-
    },
-
    State {
-
        id: Rev,
-
        state: State,
-
    },
-
    Delete {
-
        id: Rev,
-
    },
-
    React {
-
        id: Rev,
-
        reaction: Option<Reaction>,
-
        comment_id: Option<thread::CommentId>,
-
    },
-
    Assign {
-
        id: Rev,
-
        opts: AssignOptions,
-
    },
-
    Label {
-
        id: Rev,
-
        opts: LabelOptions,
-
    },
-
    List {
-
        assigned: Option<Assigned>,
-
        state: Option<State>,
-
    },
-
    Cache {
-
        id: Option<Rev>,
-
        storage: bool,
-
    },
-
}
-

-
#[derive(Debug, Default, PartialEq, Eq)]
-
pub struct AssignOptions {
-
    pub add: BTreeSet<Did>,
-
    pub delete: BTreeSet<Did>,
-
}
-

-
#[derive(Debug, Default, PartialEq, Eq)]
-
pub struct LabelOptions {
-
    pub add: BTreeSet<Label>,
-
    pub delete: BTreeSet<Label>,
-
}
-

-
#[derive(Debug)]
-
pub struct Options {
-
    pub op: Operation,
-
    pub repo: Option<RepoId>,
-
    pub announce: bool,
-
    pub quiet: bool,
-
}
-

-
impl Args for Options {
-
    fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
-
        use lexopt::prelude::*;
-

-
        let mut parser = lexopt::Parser::from_args(args);
-
        let mut op: Option<OperationName> = None;
-
        let mut id: Option<Rev> = None;
-
        let mut assigned: Option<Assigned> = None;
-
        let mut title: Option<Title> = None;
-
        let mut reaction: Option<Reaction> = None;
-
        let mut comment_id: Option<thread::CommentId> = None;
-
        let mut description: Option<String> = None;
-
        let mut state: Option<State> = Some(State::Open);
-
        let mut labels = Vec::new();
-
        let mut assignees = Vec::new();
-
        let mut format = Format::default();
-
        let mut message = Message::default();
-
        let mut reply_to = None;
-
        let mut edit_comment = None;
-
        let mut announce = true;
-
        let mut quiet = false;
-
        let mut verbose = false;
-
        let mut assign_opts = AssignOptions::default();
-
        let mut label_opts = LabelOptions::default();
-
        let mut repo = None;
-
        let mut cache_storage = false;
-

-
        while let Some(arg) = parser.next()? {
-
            match arg {
-
                Long("help") | Short('h') => {
-
                    return Err(Error::Help.into());
-
                }
-

-
                // List options.
-
                Long("all") if op.is_none() || op == Some(OperationName::List) => {
-
                    state = None;
-
                }
-
                Long("closed") if op.is_none() || op == Some(OperationName::List) => {
-
                    state = Some(State::Closed {
-
                        reason: CloseReason::Other,
-
                    });
-
                }
-
                Long("open") if op.is_none() || op == Some(OperationName::List) => {
-
                    state = Some(State::Open);
-
                }
-
                Long("solved") if op.is_none() || op == Some(OperationName::List) => {
-
                    state = Some(State::Closed {
-
                        reason: CloseReason::Solved,
-
                    });
-
                }
-

-
                // Open/Edit options.
-
                Long("title")
-
                    if op == Some(OperationName::Open) || op == Some(OperationName::Edit) =>
-
                {
-
                    let val = parser.value()?;
-
                    title = Some(term::args::string(&val).try_into()?);
-
                }
-
                Long("description")
-
                    if op == Some(OperationName::Open) || op == Some(OperationName::Edit) =>
-
                {
-
                    description = Some(parser.value()?.to_string_lossy().into());
-
                }
-
                Short('l') | Long("label") if matches!(op, Some(OperationName::Open)) => {
-
                    let val = parser.value()?;
-
                    let name = term::args::string(&val);
-
                    let label = Label::new(name)?;
-

-
                    labels.push(label);
-
                }
-
                Long("assign") if op == Some(OperationName::Open) => {
-
                    let val = parser.value()?;
-
                    let did = term::args::did(&val)?;
-

-
                    assignees.push(did);
-
                }
-

-
                // State options.
-
                Long("closed") if op == Some(OperationName::State) => {
-
                    state = Some(State::Closed {
-
                        reason: CloseReason::Other,
-
                    });
-
                }
-
                Long("open") if op == Some(OperationName::State) => {
-
                    state = Some(State::Open);
-
                }
-
                Long("solved") if op == Some(OperationName::State) => {
-
                    state = Some(State::Closed {
-
                        reason: CloseReason::Solved,
-
                    });
-
                }
-

-
                // React options.
-
                Long("emoji") if op == Some(OperationName::React) => {
-
                    if let Some(emoji) = parser.value()?.to_str() {
-
                        reaction =
-
                            Some(Reaction::from_str(emoji).map_err(|_| anyhow!("invalid emoji"))?);
-
                    }
-
                }
-
                Long("to") if op == Some(OperationName::React) => {
-
                    let oid: String = parser.value()?.to_string_lossy().into();
-
                    comment_id = Some(oid.parse()?);
-
                }
-

-
                // Show options.
-
                Long("format") if op == Some(OperationName::Show) => {
-
                    let val = parser.value()?;
-
                    let val = term::args::string(&val);
-

-
                    match val.as_str() {
-
                        "header" => format = Format::Header,
-
                        "full" => format = Format::Full,
-
                        _ => anyhow::bail!("unknown format '{val}'"),
-
                    }
-
                }
-
                Long("verbose") | Short('v') if op == Some(OperationName::Show) => {
-
                    verbose = true;
-
                }
-

-
                // Comment options.
-
                Long("message") | Short('m') if op == Some(OperationName::Comment) => {
-
                    let val = parser.value()?;
-
                    let txt = term::args::string(&val);
-

-
                    message.append(&txt);
-
                }
-
                Long("reply-to") if op == Some(OperationName::Comment) => {
-
                    let val = parser.value()?;
-
                    let rev = term::args::rev(&val)?;
-

-
                    reply_to = Some(rev);
-
                }
-
                Long("edit") if op == Some(OperationName::Comment) => {
-
                    let val = parser.value()?;
-
                    let rev = term::args::rev(&val)?;
+
pub const ABOUT: &str = "Manage issues";

-
                    edit_comment = Some(rev);
-
                }
-

-
                // Assign options
-
                Short('a') | Long("add") if op == Some(OperationName::Assign) => {
-
                    assign_opts.add.insert(term::args::did(&parser.value()?)?);
-
                }
-
                Short('d') | Long("delete") if op == Some(OperationName::Assign) => {
-
                    assign_opts
-
                        .delete
-
                        .insert(term::args::did(&parser.value()?)?);
-
                }
-
                Long("assigned") | Short('a') if assigned.is_none() => {
-
                    if let Ok(val) = parser.value() {
-
                        let peer = term::args::did(&val)?;
-
                        assigned = Some(Assigned::Peer(peer));
-
                    } else {
-
                        assigned = Some(Assigned::Me);
-
                    }
-
                }
-

-
                // Label options
-
                Short('a') | Long("add") if matches!(op, Some(OperationName::Label)) => {
-
                    let val = parser.value()?;
-
                    let name = term::args::string(&val);
-
                    let label = Label::new(name)?;
-

-
                    label_opts.add.insert(label);
-
                }
-
                Short('d') | Long("delete") if matches!(op, Some(OperationName::Label)) => {
-
                    let val = parser.value()?;
-
                    let name = term::args::string(&val);
-
                    let label = Label::new(name)?;
-

-
                    label_opts.delete.insert(label);
-
                }
-

-
                // Cache options.
-
                Long("storage") if matches!(op, Some(OperationName::Cache)) => {
-
                    cache_storage = true;
-
                }
-

-
                // Options.
-
                Long("no-announce") => {
-
                    announce = false;
-
                }
-
                Long("quiet") | Short('q') => {
-
                    quiet = true;
-
                }
-
                Long("repo") => {
-
                    let val = parser.value()?;
-
                    let rid = term::args::rid(&val)?;
-

-
                    repo = Some(rid);
-
                }
-

-
                Value(val) if op.is_none() => match val.to_string_lossy().as_ref() {
-
                    "c" | "comment" => op = Some(OperationName::Comment),
-
                    "w" | "show" => op = Some(OperationName::Show),
-
                    "d" | "delete" => op = Some(OperationName::Delete),
-
                    "e" | "edit" => op = Some(OperationName::Edit),
-
                    "l" | "list" => op = Some(OperationName::List),
-
                    "o" | "open" => op = Some(OperationName::Open),
-
                    "r" | "react" => op = Some(OperationName::React),
-
                    "s" | "state" => op = Some(OperationName::State),
-
                    "assign" => op = Some(OperationName::Assign),
-
                    "label" => op = Some(OperationName::Label),
-
                    "cache" => op = Some(OperationName::Cache),
-

-
                    unknown => anyhow::bail!("unknown operation '{}'", unknown),
-
                },
-
                Value(val) if op.is_some() => {
-
                    let val = term::args::rev(&val)?;
-
                    id = Some(val);
-
                }
-
                _ => {
-
                    return Err(anyhow!(arg.unexpected()));
-
                }
-
            }
-
        }
-

-
        let op = match op.unwrap_or_default() {
-
            OperationName::Edit => Operation::Edit {
-
                id: id.ok_or_else(|| anyhow!("an issue must be provided"))?,
-
                title,
-
                description,
-
            },
-
            OperationName::Open => Operation::Open {
-
                title,
-
                description,
-
                labels,
-
                assignees,
-
            },
-
            OperationName::Comment => match (reply_to, edit_comment) {
-
                (None, None) => Operation::Comment {
-
                    id: id.ok_or_else(|| anyhow!("an issue must be provided"))?,
-
                    message,
-
                    reply_to: None,
-
                },
-
                (None, Some(comment_id)) => Operation::CommentEdit {
-
                    id: id.ok_or_else(|| anyhow!("an issue must be provided"))?,
-
                    comment_id,
-
                    message,
-
                },
-
                (reply_to @ Some(_), None) => Operation::Comment {
-
                    id: id.ok_or_else(|| anyhow!("an issue must be provided"))?,
-
                    message,
-
                    reply_to,
-
                },
-
                (Some(_), Some(_)) => anyhow::bail!("you cannot use --reply-to with --edit"),
-
            },
-
            OperationName::Show => Operation::Show {
-
                id: id.ok_or_else(|| anyhow!("an issue must be provided"))?,
-
                format,
-
                verbose,
-
            },
-
            OperationName::State => Operation::State {
-
                id: id.ok_or_else(|| anyhow!("an issue must be provided"))?,
-
                state: state.ok_or_else(|| anyhow!("a state operation must be provided"))?,
-
            },
-
            OperationName::React => Operation::React {
-
                id: id.ok_or_else(|| anyhow!("an issue must be provided"))?,
-
                reaction,
-
                comment_id,
-
            },
-
            OperationName::Delete => Operation::Delete {
-
                id: id.ok_or_else(|| anyhow!("an issue to remove must be provided"))?,
-
            },
-
            OperationName::Assign => Operation::Assign {
-
                id: id.ok_or_else(|| anyhow!("an issue to label must be provided"))?,
-
                opts: assign_opts,
-
            },
-
            OperationName::Label => Operation::Label {
-
                id: id.ok_or_else(|| anyhow!("an issue to label must be provided"))?,
-
                opts: label_opts,
-
            },
-
            OperationName::List => Operation::List { assigned, state },
-
            OperationName::Cache => Operation::Cache {
-
                id,
-
                storage: cache_storage,
-
            },
-
        };
-

-
        Ok((
-
            Options {
-
                op,
-
                repo,
-
                announce,
-
                quiet,
-
            },
-
            vec![],
-
        ))
-
    }
-
}
-

-
pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
+
pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
    let profile = ctx.profile()?;
-
    let rid = if let Some(rid) = options.repo {
+
    let rid = if let Some(rid) = args.repo {
        rid
    } else {
        radicle::rad::cwd().map(|(_, rid)| rid)?
    };
    let repo = profile.storage.repository_mut(rid)?;
-
    let announce = options.announce
-
        && matches!(
-
            &options.op,
-
            Operation::Open { .. }
-
                | Operation::React { .. }
-
                | Operation::State { .. }
-
                | Operation::Delete { .. }
-
                | Operation::Assign { .. }
-
                | Operation::Label { .. }
-
                | Operation::Edit { .. }
-
                | Operation::Comment { .. }
-
        );
+

+
    // Fallback to [`Command::List`] if no subcommand is provided. Construct it
+
    // with the [`EmptyArgs`] provided, if any.
+
    let command = args
+
        .command
+
        .unwrap_or_else(|| Command::List(args.empty.into()));
+

+
    let announce = !args.no_announce && command.should_announce_for();
    let mut issues = term::cob::issues_mut(&profile, &repo)?;

-
    match options.op {
-
        Operation::Edit {
+
    match command {
+
        Command::Edit {
            id,
            title,
            description,
        } => {
            let signer = term::signer(&profile)?;
            let issue = edit(&mut issues, &repo, id, title, description, &signer)?;
-
            if !options.quiet {
-
                term::issue::show(&issue, issue.id(), Format::Header, false, &profile)?;
+
            if !args.quiet {
+
                term::issue::show(&issue, issue.id(), Format::Header, args.verbose, &profile)?;
            }
        }
-
        Operation::Open {
-
            title: Some(title),
-
            description: Some(description),
-
            labels,
-
            assignees,
+
        Command::Open {
+
            ref title,
+
            ref description,
+
            ref labels,
+
            ref assignees,
        } => {
            let signer = term::signer(&profile)?;
-
            let issue = issues.create(title, description, &labels, &assignees, [], &signer)?;
-
            if !options.quiet {
-
                term::issue::show(&issue, issue.id(), Format::Header, false, &profile)?;
-
            }
+
            open(
+
                title.clone(),
+
                description.clone(),
+
                labels.to_vec(),
+
                assignees.to_vec(),
+
                args.verbose,
+
                args.quiet,
+
                &mut issues,
+
                &signer,
+
                &profile,
+
            )?;
        }
-
        Operation::Comment {
-
            id,
-
            message,
-
            reply_to,
-
        } => {
-
            let reply_to = reply_to
-
                .map(|rev| rev.resolve::<radicle::git::Oid>(repo.raw()))
-
                .transpose()?;
-

-
            let signer = term::signer(&profile)?;
-
            let issue_id = id.resolve::<cob::ObjectId>(&repo.backend)?;
-
            let mut issue = issues.get_mut(&issue_id)?;
-

-
            let (root_comment_id, _) = issue.root();
-
            let body = prompt_comment(message, issue.thread(), reply_to, None)?;
-
            let comment_id =
-
                issue.comment(body, reply_to.unwrap_or(*root_comment_id), vec![], &signer)?;
-

-
            if options.quiet {
-
                term::print(comment_id);
-
            } else {
-
                let comment = issue.thread().comment(&comment_id).unwrap();
-
                term::comment::widget(&comment_id, comment, &profile).print();
+
        Command::Comment(c) => match CommentAction::from(c) {
+
            CommentAction::Comment { id, message } => {
+
                comment::comment(&profile, &repo, &mut issues, id, message, None, args.quiet)?;
            }
-
        }
-
        Operation::CommentEdit {
-
            id,
-
            comment_id,
-
            message,
-
        } => {
-
            let signer = term::signer(&profile)?;
-
            let issue_id = id.resolve::<cob::ObjectId>(&repo.backend)?;
-
            let comment_id = comment_id.resolve(&repo.backend)?;
-
            let mut issue = issues.get_mut(&issue_id)?;
-

-
            let comment = issue
-
                .thread()
-
                .comment(&comment_id)
-
                .ok_or(anyhow::anyhow!("comment '{comment_id}' not found"))?;
-

-
            let body = prompt_comment(
+
            CommentAction::Reply {
+
                id,
                message,
-
                issue.thread(),
-
                comment.reply_to(),
-
                Some(comment.body()),
-
            )?;
-
            issue.edit_comment(comment_id, body, vec![], &signer)?;
-

-
            if options.quiet {
-
                term::print(comment_id);
+
                reply_to,
+
            } => comment::comment(
+
                &profile,
+
                &repo,
+
                &mut issues,
+
                id,
+
                message,
+
                Some(reply_to),
+
                args.quiet,
+
            )?,
+
            CommentAction::Edit {
+
                id,
+
                message,
+
                to_edit,
+
            } => comment::edit(
+
                &profile,
+
                &repo,
+
                &mut issues,
+
                id,
+
                message,
+
                to_edit,
+
                args.quiet,
+
            )?,
+
        },
+
        Command::Show { id } => {
+
            let format = if args.header {
+
                term::issue::Format::Header
            } else {
-
                let comment = issue.thread().comment(&comment_id).unwrap();
-
                term::comment::widget(&comment_id, comment, &profile).print();
-
            }
-
        }
-
        Operation::Show {
-
            id,
-
            format,
-
            verbose,
-
        } => {
+
                term::issue::Format::Full
+
            };
+

            let id = id.resolve(&repo.backend)?;
            let issue = issues
                .get(&id)
@@ -585,14 +127,17 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                    hint: "reset the cache with `rad issue cache` and try again",
                })?
                .context("No issue with the given ID exists")?;
-
            term::issue::show(&issue, &id, format, verbose, &profile)?;
+
            term::issue::show(&issue, &id, format, args.verbose, &profile)?;
        }
-
        Operation::State { id, state } => {
-
            let signer = term::signer(&profile)?;
+
        Command::State { id, target_state } => {
+
            let to: StateArg = target_state.into();
            let id = id.resolve(&repo.backend)?;
+
            let signer = term::signer(&profile)?;
            let mut issue = issues.get_mut(&id)?;
+
            let state = to.into();
            issue.lifecycle(state, &signer)?;
-
            if !options.quiet {
+

+
            if !args.quiet {
                let success =
                    |status| term::success!("Issue {} is now {status}", term::format::cob(&id));
                match state {
@@ -604,7 +149,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                };
            }
        }
-
        Operation::React {
+
        Command::React {
            id,
            reaction,
            comment_id,
@@ -613,7 +158,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
            if let Ok(mut issue) = issues.get_mut(&id) {
                let signer = term::signer(&profile)?;
                let comment_id = match comment_id {
-
                    Some(cid) => cid,
+
                    Some(cid) => cid.resolve(&repo.backend)?,
                    None => *term::io::comment_select(&issue).map(|(cid, _)| cid)?,
                };
                let reaction = match reaction {
@@ -624,28 +169,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                issue.react(comment_id, reaction, true, &signer)?;
            }
        }
-
        Operation::Open {
-
            ref title,
-
            ref description,
-
            ref labels,
-
            ref assignees,
-
        } => {
-
            let signer = term::signer(&profile)?;
-
            open(
-
                title.clone(),
-
                description.clone(),
-
                labels.to_vec(),
-
                assignees.to_vec(),
-
                &options,
-
                &mut issues,
-
                &signer,
-
                &profile,
-
            )?;
-
        }
-
        Operation::Assign {
-
            id,
-
            opts: AssignOptions { add, delete },
-
        } => {
+
        Command::Assign { id, add, delete } => {
            let signer = term::signer(&profile)?;
            let id = id.resolve(&repo.backend)?;
            let Ok(mut issue) = issues.get_mut(&id) else {
@@ -659,11 +183,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                .collect::<Vec<_>>();
            issue.assign(assignees, &signer)?;
        }
-
        Operation::Label {
-
            id,
-
            opts: LabelOptions { add, delete },
-
        } => {
-
            let signer = term::signer(&profile)?;
+
        Command::Label { id, add, delete } => {
            let id = id.resolve(&repo.backend)?;
            let Ok(mut issue) = issues.get_mut(&id) else {
                anyhow::bail!("Issue `{id}` not found");
@@ -674,17 +194,24 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                .chain(add.iter())
                .cloned()
                .collect::<Vec<_>>();
+
            let signer = term::signer(&profile)?;
            issue.label(labels, &signer)?;
        }
-
        Operation::List { assigned, state } => {
-
            list(issues, &assigned, &state, &profile)?;
+
        Command::List(ref list_args) => {
+
            list(
+
                issues,
+
                &list_args.assigned,
+
                &((&list_args.state).into()),
+
                &profile,
+
                args.verbose,
+
            )?;
        }
-
        Operation::Delete { id } => {
-
            let signer = term::signer(&profile)?;
+
        Command::Delete { id } => {
            let id = id.resolve(&repo.backend)?;
+
            let signer = term::signer(&profile)?;
            issues.remove(&id, &signer)?;
        }
-
        Operation::Cache { id, storage } => {
+
        Command::Cache { id, storage } => {
            let mode = if storage {
                cache::CacheMode::Storage
            } else {
@@ -719,6 +246,7 @@ fn list<C>(
    assigned: &Option<Assigned>,
    state: &Option<State>,
    profile: &profile::Profile,
+
    verbose: bool,
) -> anyhow::Result<()>
where
    C: issue::cache::Issues,
@@ -786,7 +314,7 @@ where
        let assigned: String = issue
            .assignees()
            .map(|did| {
-
                let (alias, _) = Author::new(did.as_key(), profile, false).labels();
+
                let (alias, _) = Author::new(did.as_key(), profile, verbose).labels();

                alias.content().to_owned()
            })
@@ -797,7 +325,7 @@ where
        labels.sort();

        let author = issue.author().id;
-
        let (alias, did) = Author::new(&author, profile, false).labels();
+
        let (alias, did) = Author::new(&author, profile, verbose).labels();

        mk_issue_row(id, issue, assigned, labels, alias, did)
    }));
@@ -844,13 +372,14 @@ fn open<R, G>(
    description: Option<String>,
    labels: Vec<Label>,
    assignees: Vec<Did>,
-
    options: &Options,
+
    verbose: bool,
+
    quiet: bool,
    cache: &mut issue::Cache<issue::Issues<'_, R>, cob::cache::StoreWriter>,
    signer: &Device<G>,
    profile: &Profile,
) -> anyhow::Result<()>
where
-
    R: ReadRepository + WriteRepository + cob::Store<Namespace = NodeId>,
+
    R: WriteRepository + cob::Store<Namespace = NodeId>,
    G: crypto::signature::Signer<crypto::Signature>,
{
    let (title, description) = if let (Some(t), Some(d)) = (title.as_ref(), description.as_ref()) {
@@ -869,8 +398,8 @@ where
        signer,
    )?;

-
    if !options.quiet {
-
        term::issue::show(&issue, issue.id(), Format::Header, false, profile)?;
+
    if !quiet {
+
        term::issue::show(&issue, issue.id(), Format::Header, verbose, profile)?;
    }
    Ok(())
}
@@ -884,7 +413,7 @@ fn edit<'a, 'g, R, G>(
    signer: &Device<G>,
) -> anyhow::Result<issue::IssueMut<'a, 'g, R, cob::cache::StoreWriter>>
where
-
    R: WriteRepository + ReadRepository + cob::Store<Namespace = NodeId>,
+
    R: WriteRepository + cob::Store<Namespace = NodeId>,
    G: crypto::signature::Signer<crypto::Signature>,
{
    let id = id.resolve(&repo.backend)?;
@@ -924,94 +453,3 @@ where

    Ok(issue)
}
-

-
/// Get a comment from the user, by prompting.
-
pub fn prompt_comment(
-
    message: Message,
-
    thread: &thread::Thread,
-
    mut reply_to: Option<Oid>,
-
    edit: Option<&str>,
-
) -> anyhow::Result<String> {
-
    let (chase, missing) = {
-
        let mut chase = Vec::with_capacity(thread.len());
-
        let mut missing = None;
-

-
        while let Some(id) = reply_to {
-
            if let Some(comment) = thread.comment(&id) {
-
                chase.push(comment);
-
                reply_to = comment.reply_to();
-
            } else {
-
                missing = reply_to;
-
                break;
-
            }
-
        }
-

-
        (chase, missing)
-
    };
-

-
    let quotes = if chase.is_empty() {
-
        ""
-
    } else {
-
        "Quotes (lines starting with '>') will be preserved. Please remove those that you do not intend to keep.\n"
-
    };
-

-
    let mut buffer = term::format::html::commented(format!("HTML comments, such as this one, are deleted before posting.\n{quotes}Saving an empty file aborts the operation.").as_str());
-
    buffer.push('\n');
-

-
    for comment in chase.iter().rev() {
-
        buffer.reserve(2);
-
        buffer.push('\n');
-
        comment_quoted(comment, &mut buffer);
-
    }
-

-
    if let Some(id) = missing {
-
        buffer.push('\n');
-
        buffer.push_str(
-
            term::format::html::commented(
-
                format!("The comment with ID {id} that was replied to could not be found.")
-
                    .as_str(),
-
            )
-
            .as_str(),
-
        );
-
    }
-

-
    if let Some(edit) = edit {
-
        if !chase.is_empty() {
-
            buffer.push_str(
-
                "\n<!-- The contents of the comment you are editing follow below this line. -->\n",
-
            );
-
        }
-
        buffer.reserve(2 + edit.len());
-
        buffer.push('\n');
-
        buffer.push_str(edit);
-
    }
-

-
    let body = message.get(&buffer)?;
-

-
    if body.is_empty() {
-
        anyhow::bail!("aborting operation due to empty comment");
-
    }
-
    Ok(body)
-
}
-

-
fn comment_quoted(comment: &thread::Comment, buffer: &mut String) {
-
    let body = comment.body();
-
    let lines = body.lines();
-

-
    let hint = {
-
        let (lower, upper) = lines.size_hint();
-
        upper.unwrap_or(lower)
-
    };
-

-
    buffer.push_str(format!("{} wrote:\n", comment.author()).as_str());
-
    buffer.reserve(body.len() + hint * 2);
-

-
    for line in lines {
-
        buffer.push('>');
-
        if !line.is_empty() {
-
            buffer.push(' ');
-
        }
-
        buffer.push_str(line);
-
        buffer.push('\n');
-
    }
-
}
added crates/radicle-cli/src/commands/issue/args.rs
@@ -0,0 +1,483 @@
+
#![warn(missing_docs)]
+
#![warn(clippy::missing_docs_in_private_items)]
+

+
//! Argument parsing for the `rad issue` command.
+

+
use std::str::FromStr;
+

+
use clap::{Parser, Subcommand};
+
use radicle::{
+
    cob::{Label, Reaction, Title},
+
    identity::{did::DidError, Did, RepoId},
+
    issue::{CloseReason, State},
+
};
+

+
use crate::{git::Rev, terminal::patch::Message};
+

+
/// Command line Peer argument.
+
#[derive(Default, Debug, Clone, PartialEq, Eq)]
+
pub enum Assigned {
+
    /// Filter issues assigned to the local DID
+
    #[default]
+
    Me,
+
    /// Filter issues assigned to the given DID
+
    Peer(Did),
+
}
+

+
/// Subcommands and arguments for the `rad issue` command
+
#[derive(Parser, Debug)]
+
#[command(disable_version_flag = true)]
+
pub struct Args {
+
    /// Subcommand for `rad issue`
+
    #[command(subcommand)]
+
    pub(crate) command: Option<Command>,
+

+
    /// Do not print anything
+
    #[arg(short, long)]
+
    #[clap(global = true)]
+
    pub(crate) quiet: bool,
+

+
    /// Do not announce issue changes to the network
+
    #[arg(long)]
+
    #[arg(value_name = "no-announce")]
+
    #[clap(global = true)]
+
    pub(crate) no_announce: bool,
+

+
    /// Show only the issue header, hiding the comments
+
    #[arg(long)]
+
    #[clap(global = true)]
+
    pub(crate) header: bool,
+

+
    /// Operate on the given repository (default: cwd)
+
    #[arg(value_name = "RID")]
+
    #[arg(long, short)]
+
    #[clap(global = true)]
+
    pub(crate) repo: Option<RepoId>,
+

+
    /// Enable verbose output
+
    #[arg(long, short)]
+
    #[clap(global = true)]
+
    pub(crate) verbose: bool,
+

+
    /// Arguments for the empty subcommand. Will fallback to [`Command::List`].
+
    #[clap(flatten)]
+
    pub(crate) empty: EmptyArgs,
+
}
+

+
/// Commands to create, view, and edit Radicle issues
+
#[derive(Subcommand, Debug)]
+
pub(crate) enum Command {
+
    /// Add or delete assignees from an issue
+
    Assign {
+
        /// ID of the issue
+
        #[arg(value_name = "ISSUE_ID")]
+
        id: Rev,
+

+
        /// Add an assignee (may be specified multiple times, takes precedence over `--delete`)
+
        #[arg(long, short)]
+
        #[arg(value_name = "DID")]
+
        #[arg(action = clap::ArgAction::Append)]
+
        add: Vec<Did>,
+

+
        /// Delete an assignee (may be specified multiple times)
+
        #[arg(long, short)]
+
        #[arg(value_name = "DID")]
+
        #[arg(action = clap::ArgAction::Append)]
+
        delete: Vec<Did>,
+
    },
+
    /// Re-cache all issues that can be found in Radicle storage
+
    Cache {
+
        /// Optionally choose an issue to re-cache
+
        #[arg(value_name = "ISSUE_ID")]
+
        id: Option<Rev>,
+

+
        /// Operate on storage
+
        #[arg(long)]
+
        storage: bool,
+
    },
+
    /// Add a comment to an issue
+
    Comment(CommentArgs),
+
    /// Edit the title and description of an issue
+
    Edit {
+
        /// ID of the issue
+
        #[arg(value_name = "ISSUE_ID")]
+
        id: Rev,
+

+
        /// The new title to set
+
        #[arg(long, short)]
+
        title: Option<Title>,
+

+
        /// The new description to set
+
        #[arg(long, short)]
+
        description: Option<String>,
+
    },
+
    /// Delete an issue
+
    Delete {
+
        /// ID of the issue
+
        #[arg(value_name = "ISSUE_ID")]
+
        id: Rev,
+
    },
+
    /// Add or delete labels from an issue
+
    Label {
+
        /// ID of the issue
+
        #[arg(value_name = "ISSUE_ID")]
+
        id: Rev,
+

+
        /// Add a label (may be specified multiple times, takes precedence over `--delete`)
+
        #[arg(long, short)]
+
        #[arg(value_name = "label")]
+
        #[arg(action = clap::ArgAction::Append)]
+
        add: Vec<Label>,
+

+
        /// Delete a label (may be specified multiple times)
+
        #[arg(long, short)]
+
        #[arg(value_name = "label")]
+
        #[arg(action = clap::ArgAction::Append)]
+
        delete: Vec<Label>,
+
    },
+
    /// List issues, optionally filtering them
+
    List(ListArgs),
+
    /// Open a new issue
+
    Open {
+
        /// The title of the issue
+
        #[arg(long, short)]
+
        title: Option<Title>,
+

+
        /// The description of the issue
+
        #[arg(long, short)]
+
        description: Option<String>,
+

+
        /// A set of labels to associate with the issue
+
        #[arg(long)]
+
        labels: Vec<Label>,
+

+
        /// A set of DIDs to assign to the issue
+
        #[arg(value_name = "DID")]
+
        #[arg(long)]
+
        assignees: Vec<Did>,
+
    },
+
    /// Add a reaction emoji to an issue or comment
+
    React {
+
        /// ID of the issue
+
        #[arg(value_name = "ISSUE_ID")]
+
        id: Rev,
+

+
        /// The emoji reaction
+
        #[arg(long = "emoji")]
+
        #[arg(value_name = "CHAR")]
+
        reaction: Option<Reaction>,
+

+
        /// Optionally react to a comment
+
        #[arg(long = "to")]
+
        #[arg(value_name = "COMMENT_ID")]
+
        comment_id: Option<Rev>,
+
    },
+
    /// Show a specific issue
+
    Show {
+
        /// ID of the issue
+
        #[arg(value_name = "ISSUE_ID")]
+
        id: Rev,
+
    },
+
    /// Transition the state of an issue
+
    State {
+
        /// ID of the issue
+
        #[arg(value_name = "ISSUE_ID")]
+
        id: Rev,
+

+
        /// The desired target state
+
        #[clap(flatten)]
+
        target_state: StateArgs,
+
    },
+
}
+

+
impl Command {
+
    /// Returns `true` if the changes made by the command should announce to the
+
    /// network.
+
    pub(crate) fn should_announce_for(&self) -> bool {
+
        match self {
+
            Command::Open { .. }
+
            | Command::React { .. }
+
            | Command::State { .. }
+
            | Command::Delete { .. }
+
            | Command::Assign { .. }
+
            | Command::Label { .. }
+
            // Special handling for `--edit` will be removed in the future.
+
            | Command::Edit { .. } => true,
+
            Command::Comment(args) => !args.is_edit(),
+
            _ => false,
+
        }
+
    }
+
}
+

+
/// Arguments for the empty subcommand.
+
#[derive(Parser, Debug, Default)]
+
pub(crate) struct EmptyArgs {
+
    #[arg(long, name = "DID")]
+
    #[arg(default_missing_value = "me")]
+
    #[arg(num_args = 0..=1)]
+
    #[arg(hide = true)]
+
    #[allow(clippy::missing_docs_in_private_items)] // flattened
+
    pub(crate) assigned: Option<Assigned>,
+

+
    #[clap(flatten)]
+
    #[allow(clippy::missing_docs_in_private_items)] // flattened
+
    pub(crate) state: EmptyStateArgs,
+
}
+

+
/// Counterpart to [`ListStateArgs`] for the empty subcommand.
+
#[derive(Parser, Debug, Default)]
+
#[group(id = "state", required = false, multiple = false)]
+
pub(crate) struct EmptyStateArgs {
+
    #[arg(long, hide = true)]
+
    #[allow(clippy::missing_docs_in_private_items)] // hidden
+
    all: bool,
+

+
    #[arg(long, hide = true)]
+
    #[allow(clippy::missing_docs_in_private_items)] // hidden
+
    open: bool,
+

+
    #[arg(long, hide = true)]
+
    #[allow(clippy::missing_docs_in_private_items)] // hidden
+
    closed: bool,
+

+
    #[arg(long, hide = true)]
+
    #[allow(clippy::missing_docs_in_private_items)] // hidden
+
    solved: bool,
+
}
+

+
/// Arguments for the [`Command::List`] subcommand.
+
#[derive(Parser, Debug, Default)]
+
pub(crate) struct ListArgs {
+
    /// Filter for the list of issues that are assigned to '<DID>' (default: me)
+
    #[arg(long, name = "DID")]
+
    #[arg(default_missing_value = "me")]
+
    #[arg(num_args = 0..=1)]
+
    pub(crate) assigned: Option<Assigned>,
+

+
    #[clap(flatten)]
+
    #[allow(clippy::missing_docs_in_private_items)] // flattened
+
    pub(crate) state: ListStateArgs,
+
}
+

+
#[derive(Parser, Debug, Default)]
+
#[group(id = "state", required = false, multiple = false)]
+
pub(crate) struct ListStateArgs {
+
    /// List all issues
+
    #[arg(long)]
+
    all: bool,
+

+
    /// List only open issues (default)
+
    #[arg(long)]
+
    open: bool,
+

+
    /// List only closed issues
+
    #[arg(long)]
+
    closed: bool,
+

+
    /// List only solved issues
+
    #[arg(long)]
+
    solved: bool,
+
}
+

+
impl From<&ListStateArgs> for Option<State> {
+
    fn from(args: &ListStateArgs) -> Self {
+
        match (args.all, args.open, args.closed, args.solved) {
+
            (true, false, false, false) => None,
+
            (false, true, false, false) | (false, false, false, false) => Some(State::Open),
+
            (false, false, true, false) => Some(State::Closed {
+
                reason: CloseReason::Other,
+
            }),
+
            (false, false, false, true) => Some(State::Closed {
+
                reason: CloseReason::Solved,
+
            }),
+
            _ => unreachable!(),
+
        }
+
    }
+
}
+

+
impl From<EmptyStateArgs> for ListStateArgs {
+
    fn from(args: EmptyStateArgs) -> Self {
+
        Self {
+
            all: args.all,
+
            open: args.open,
+
            closed: args.closed,
+
            solved: args.solved,
+
        }
+
    }
+
}
+

+
impl From<EmptyArgs> for ListArgs {
+
    fn from(args: EmptyArgs) -> Self {
+
        Self {
+
            assigned: args.assigned,
+
            state: ListStateArgs::from(args.state),
+
        }
+
    }
+
}
+

+
/// Arguments for the [`Command::Comment`] subcommand.
+
#[derive(Parser, Debug)]
+
pub(crate) struct CommentArgs {
+
    /// ID of the issue
+
    #[arg(value_name = "ISSUE_ID")]
+
    id: Rev,
+

+
    /// The body of the comment
+
    #[arg(long, short)]
+
    #[arg(value_name = "MESSAGE")]
+
    message: Message,
+

+
    /// Optionally, the comment to reply to. If not specified, the comment
+
    /// will be in reply to the issue itself
+
    #[arg(long, value_name = "COMMENT_ID")]
+
    #[arg(conflicts_with = "edit")]
+
    reply_to: Option<Rev>,
+

+
    /// Edit a comment by specifying its ID
+
    #[arg(long, value_name = "COMMENT_ID")]
+
    #[arg(conflicts_with = "reply_to")]
+
    edit: Option<Rev>,
+
}
+

+
impl CommentArgs {
+
    // TODO(finto): this is only needed to avoid announcing edits for the time
+
    // being
+
    /// If the comment is editing an existing comment
+
    pub(crate) fn is_edit(&self) -> bool {
+
        self.edit.is_some()
+
    }
+
}
+

+
/// The action that should be performed based on the supplied [`CommentArgs`].
+
#[derive(Parser, Debug)]
+
pub(crate) enum CommentAction {
+
    /// Comment to the main issue thread.
+
    Comment {
+
        /// ID of the issue
+
        #[arg(value_name = "ISSUE_ID")]
+
        id: Rev,
+
        /// The message of the comment.
+
        message: Message,
+
    },
+
    /// Reply to a specific comment in the issue.
+
    Reply {
+
        /// ID of the issue
+
        #[arg(value_name = "ISSUE_ID")]
+
        id: Rev,
+
        /// The message that is being used to reply to the comment.
+
        message: Message,
+
        /// The comment ID that is being replied to.
+
        reply_to: Rev,
+
    },
+
    /// Edit a specific comment in the issue.
+
    Edit {
+
        /// ID of the issue
+
        #[arg(value_name = "ISSUE_ID")]
+
        id: Rev,
+
        /// The message that is being used to edit the comment.
+
        message: Message,
+
        /// The comment ID that is being edited.
+
        to_edit: Rev,
+
    },
+
}
+

+
impl From<CommentArgs> for CommentAction {
+
    fn from(
+
        CommentArgs {
+
            id,
+
            message,
+
            reply_to,
+
            edit,
+
        }: CommentArgs,
+
    ) -> Self {
+
        match (reply_to, edit) {
+
            (Some(_), Some(_)) => {
+
                unreachable!("the argument '--reply-to' cannot be used with '--edit'")
+
            }
+
            (Some(reply_to), None) => Self::Reply {
+
                id,
+
                message,
+
                reply_to,
+
            },
+
            (None, Some(to_edit)) => Self::Edit {
+
                id,
+
                message,
+
                to_edit,
+
            },
+
            (None, None) => Self::Comment { id, message },
+
        }
+
    }
+
}
+

+
/// Arguments for the [`Command::State`] subcommand.
+
#[derive(Parser, Debug)]
+
#[group(id = "state", required = true, multiple = false)]
+
pub(crate) struct StateArgs {
+
    /// Change the state to 'open'
+
    #[arg(long)]
+
    #[arg(group = "state")]
+
    pub(crate) open: bool,
+

+
    /// Change the state to 'closed'
+
    #[arg(long)]
+
    #[arg(group = "state")]
+
    pub(crate) closed: bool,
+

+
    /// Change the state to 'solved'
+
    #[arg(long)]
+
    #[arg(group = "state")]
+
    pub(crate) solved: bool,
+
}
+

+
impl From<StateArgs> for StateArg {
+
    fn from(state: StateArgs) -> Self {
+
        // These are mutually exclusive, guaranteed by clap grouping
+
        match (state.open, state.closed, state.solved) {
+
            (true, _, _) => StateArg::Open,
+
            (_, true, _) => StateArg::Closed,
+
            (_, _, true) => StateArg::Solved,
+
            _ => unreachable!(),
+
        }
+
    }
+
}
+

+
/// Argument value for transition an issue to the given [`State`].
+
#[derive(Clone, Copy, Debug)]
+
pub(crate) enum StateArg {
+
    /// Open issues.
+
    /// Maps to [`State::Open`].
+
    Open,
+
    /// Closed issues.
+
    /// Maps to [`State::Closed`] and [`CloseReason::Other`].
+
    Closed,
+
    /// Solved issues.
+
    /// Maps to [`State::Closed`] and [`CloseReason::Solved`].
+
    Solved,
+
}
+

+
impl From<StateArg> for State {
+
    fn from(value: StateArg) -> Self {
+
        match value {
+
            StateArg::Open => Self::Open,
+
            StateArg::Closed => Self::Closed {
+
                reason: CloseReason::Other,
+
            },
+
            StateArg::Solved => Self::Closed {
+
                reason: CloseReason::Solved,
+
            },
+
        }
+
    }
+
}
+

+
impl FromStr for Assigned {
+
    type Err = DidError;
+

+
    fn from_str(s: &str) -> Result<Self, Self::Err> {
+
        if s == "me" {
+
            Ok(Assigned::Me)
+
        } else {
+
            let value = s.parse::<Did>()?;
+
            Ok(Assigned::Peer(value))
+
        }
+
    }
+
}
added crates/radicle-cli/src/commands/issue/comment.rs
@@ -0,0 +1,166 @@
+
use radicle::cob::thread;
+
use radicle::storage::WriteRepository;
+
use radicle::Profile;
+
use radicle::{cob, git, issue, storage};
+

+
use crate::git::Rev;
+
use crate::terminal as term;
+
use crate::terminal::patch::Message;
+
use crate::terminal::Element as _;
+

+
pub(super) fn comment(
+
    profile: &Profile,
+
    repo: &storage::git::Repository,
+
    issues: &mut issue::Cache<
+
        issue::Issues<'_, storage::git::Repository>,
+
        cob::cache::Store<cob::cache::Write>,
+
    >,
+
    id: Rev,
+
    message: Message,
+
    reply_to: Option<Rev>,
+
    quiet: bool,
+
) -> Result<(), anyhow::Error> {
+
    let reply_to = reply_to
+
        .map(|rev| rev.resolve::<git::Oid>(repo.raw()))
+
        .transpose()?;
+
    let signer = term::signer(profile)?;
+
    let issue_id = id.resolve::<cob::ObjectId>(&repo.backend)?;
+
    let mut issue = issues.get_mut(&issue_id)?;
+
    let (root_comment_id, _) = issue.root();
+
    let body = prompt_comment(message, issue.thread(), reply_to, None)?;
+
    let comment_id = issue.comment(body, reply_to.unwrap_or(*root_comment_id), vec![], &signer)?;
+
    if quiet {
+
        term::print(comment_id);
+
    } else {
+
        let comment = issue.thread().comment(&comment_id).unwrap();
+
        term::comment::widget(&comment_id, comment, profile).print();
+
    }
+
    Ok(())
+
}
+

+
pub(super) fn edit(
+
    profile: &Profile,
+
    repo: &storage::git::Repository,
+
    issues: &mut issue::Cache<
+
        issue::Issues<'_, storage::git::Repository>,
+
        cob::cache::Store<cob::cache::Write>,
+
    >,
+
    id: Rev,
+
    message: Message,
+
    comment_id: Rev,
+
    quiet: bool,
+
) -> Result<(), anyhow::Error> {
+
    let signer = term::signer(profile)?;
+
    let issue_id = id.resolve::<cob::ObjectId>(&repo.backend)?;
+
    let comment_id = comment_id.resolve(&repo.backend)?;
+
    let mut issue = issues.get_mut(&issue_id)?;
+
    let comment = issue
+
        .thread()
+
        .comment(&comment_id)
+
        .ok_or(anyhow::anyhow!("comment '{comment_id}' not found"))?;
+
    let body = prompt_comment(
+
        message,
+
        issue.thread(),
+
        comment.reply_to(),
+
        Some(comment.body()),
+
    )?;
+
    issue.edit_comment(comment_id, body, vec![], &signer)?;
+
    if quiet {
+
        term::print(comment_id);
+
    } else {
+
        let comment = issue.thread().comment(&comment_id).unwrap();
+
        term::comment::widget(&comment_id, comment, profile).print();
+
    }
+
    Ok(())
+
}
+

+
/// Get a comment from the user, by prompting.
+
fn prompt_comment(
+
    message: Message,
+
    thread: &thread::Thread,
+
    mut reply_to: Option<git::Oid>,
+
    edit: Option<&str>,
+
) -> anyhow::Result<String> {
+
    let (chase, missing) = {
+
        let mut chase = Vec::with_capacity(thread.len());
+
        let mut missing = None;
+
        while let Some(id) = reply_to {
+
            if let Some(comment) = thread.comment(&id) {
+
                chase.push(comment);
+
                reply_to = comment.reply_to();
+
            } else {
+
                missing = reply_to;
+
                break;
+
            }
+
        }
+

+
        (chase, missing)
+
    };
+

+
    let quotes = if chase.is_empty() {
+
        ""
+
    } else {
+
        "Quotes (lines starting with '>') will be preserved. Please remove those that you do not intend to keep.\n"
+
    };
+

+
    let mut buffer = term::format::html::commented(format!("HTML comments, such as this one, are deleted before posting.\n{quotes}Saving an empty file aborts the operation.").as_str());
+
    buffer.push('\n');
+

+
    for comment in chase.iter().rev() {
+
        buffer.reserve(2);
+
        buffer.push('\n');
+
        comment_quoted(comment, &mut buffer);
+
    }
+

+
    if let Some(id) = missing {
+
        buffer.push('\n');
+
        buffer.push_str(
+
            term::format::html::commented(
+
                format!("The comment with ID {id} that was replied to could not be found.")
+
                    .as_str(),
+
            )
+
            .as_str(),
+
        );
+
    }
+

+
    if let Some(edit) = edit {
+
        if !chase.is_empty() {
+
            buffer.push_str(
+
                "\n<!-- The contents of the comment you are editing follow below this line. -->\n",
+
            );
+
        }
+

+
        buffer.reserve(2 + edit.len());
+
        buffer.push('\n');
+
        buffer.push_str(edit);
+
    }
+

+
    let body = message.get(&buffer)?;
+
    if body.is_empty() {
+
        anyhow::bail!("aborting operation due to empty comment");
+
    }
+

+
    Ok(body)
+
}
+

+
fn comment_quoted(comment: &thread::Comment, buffer: &mut String) {
+
    let body = comment.body();
+
    let lines = body.lines();
+
    let hint = {
+
        let (lower, upper) = lines.size_hint();
+
        upper.unwrap_or(lower)
+
    };
+

+
    buffer.push_str(format!("{} wrote:\n", comment.author()).as_str());
+
    buffer.reserve(body.len() + hint * 2);
+

+
    for line in lines {
+
        buffer.push('>');
+
        if !line.is_empty() {
+
            buffer.push(' ');
+
        }
+

+
        buffer.push_str(line);
+
        buffer.push('\n');
+
    }
+
}
modified crates/radicle-cli/src/main.rs
@@ -3,16 +3,22 @@ use std::io::{self, Write};
use std::{io::ErrorKind, iter, process};

use anyhow::anyhow;
+
use clap::builder::styling::AnsiColor;
+
use clap::builder::Styles;
+
use clap::{Parser, Subcommand};

use radicle::version::Version;
use radicle_cli::commands::*;
use radicle_cli::terminal as term;

pub const NAME: &str = "rad";
+
pub const GIT_HEAD: &str = env!("GIT_HEAD");
pub const PKG_VERSION: &str = env!("CARGO_PKG_VERSION");
pub const RADICLE_VERSION: &str = env!("RADICLE_VERSION");
+
pub const RADICLE_VERSION_LONG: &str =
+
    concat!(env!("RADICLE_VERSION"), " (", env!("GIT_HEAD"), ")");
pub const DESCRIPTION: &str = "Radicle command line interface";
-
pub const GIT_HEAD: &str = env!("GIT_HEAD");
+
pub const LONG_DESCRIPTION: &str = "Radicle is a sovereign code forge built on Git.";
pub const TIMESTAMP: &str = env!("SOURCE_DATE_EPOCH");
pub const VERSION: Version = Version {
    name: NAME,
@@ -20,6 +26,28 @@ pub const VERSION: Version = Version {
    commit: GIT_HEAD,
    timestamp: TIMESTAMP,
};
+
const STYLES: Styles = Styles::styled()
+
    .header(AnsiColor::Magenta.on_default().bold())
+
    .usage(AnsiColor::Magenta.on_default().bold())
+
    .placeholder(AnsiColor::Cyan.on_default());
+

+
/// Radicle command line interface
+
#[derive(Parser, Debug)]
+
#[command(name = NAME)]
+
#[command(version = RADICLE_VERSION)]
+
#[command(long_version = RADICLE_VERSION_LONG)]
+
#[command(propagate_version = true)]
+
#[command(styles = STYLES)]
+
struct CliArgs {
+
    #[command(subcommand)]
+
    pub command: Option<Commands>,
+
}
+

+
#[derive(Subcommand, Debug)]
+
enum Commands {
+
    #[command(about = radicle_cli::commands::issue::ABOUT)]
+
    Issue(issue::Args),
+
}

#[derive(Debug)]
enum Command {
@@ -133,6 +161,13 @@ fn run(command: Command) -> Result<(), Option<anyhow::Error>> {
    Ok(())
}

+
/// Runs a `rad` command. `exe` expects the commands' name, e.g. `issue`,
+
/// `args` expects all other arguments.
+
///
+
/// For commands that are already migrated to `clap`, we need to parse the
+
/// arguments again. This needs to be done for each migrated command
+
/// individually, otherwise `clap` would fail to parse on an non-migrated and
+
/// therefore unknown command.
fn run_other(exe: &str, args: &[OsString]) -> Result<(), Option<anyhow::Error>> {
    match exe {
        "auth" => {
@@ -189,7 +224,9 @@ fn run_other(exe: &str, args: &[OsString]) -> Result<(), Option<anyhow::Error>>
            );
        }
        "issue" => {
-
            term::run_command_args::<issue::Options, _>(issue::HELP, issue::run, args.to_vec());
+
            if let Some(Commands::Issue(args)) = CliArgs::parse().command {
+
                term::run_command_fn(issue::run, args);
+
            }
        }
        "ls" => {
            term::run_command_args::<ls::Options, _>(ls::HELP, ls::run, args.to_vec());
modified crates/radicle-cli/src/terminal.rs
@@ -14,6 +14,8 @@ pub mod upload_pack;
use std::ffi::OsString;
use std::process;

+
use clap::Parser;
+

pub use radicle_term::*;

use radicle::profile::{Home, Profile};
@@ -53,6 +55,22 @@ where
    }
}

+
/// Execute a function `cmd` that runs a command with parsed the `args`
+
/// and a default context.
+
pub fn run_command_fn<F, P: Parser>(cmd: F, args: P) -> !
+
where
+
    F: FnOnce(P, DefaultContext) -> anyhow::Result<()>,
+
{
+
    match cmd(args, DefaultContext) {
+
        Ok(()) => process::exit(0),
+
        Err(err) => {
+
            // First parameter is not used and can just be empty.
+
            fail("", &err);
+
            process::exit(1);
+
        }
+
    }
+
}
+

pub fn run_command<A, C>(help: Help, cmd: C) -> !
where
    A: Args,
modified crates/radicle-cli/src/terminal/patch.rs
@@ -113,6 +113,12 @@ impl Message {
    }
}

+
impl From<String> for Message {
+
    fn from(value: String) -> Self {
+
        Message::Text(value)
+
    }
+
}
+

pub const PATCH_MSG: &str = r#"
<!--
Please enter a patch message for your changes. An empty
modified crates/radicle-cli/tests/commands.rs
@@ -87,6 +87,11 @@ fn program_reports_version(program: &str) -> bool {
}

#[test]
+
fn rad_help() {
+
    Environment::alice(["rad-help"]);
+
}
+

+
#[test]
fn rad_auth() {
    test("examples/rad-auth.md", Path::new("."), None, []).unwrap();
}
@@ -116,6 +121,11 @@ fn rad_issue() {
}

#[test]
+
fn rad_issue_list() {
+
    Environment::alice(["rad-init", "rad-issue", "rad-issue-list"]);
+
}
+

+
#[test]
fn rad_cob_update() {
    Environment::alice(["rad-init", "rad-cob-log"]);
}