Radish alpha
h
rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
Radicle Heartwood Protocol & Stack
Radicle
Git
cli: Migrate `rad issue` test examples
Draft did:key:z6MkgFq6...nBGz opened 7 months ago
9 files changed +659 -539 646d4360 87a865a5
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,58 @@ 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_complete"
+
version = "4.5.57"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "4d9501bd3f5f09f7bbee01da9a511073ed30a80cd7a509f1214bb74eadea71ad"
+
dependencies = [
+
 "clap",
+
 "clap_lex",
+
 "is_executable",
+
 "shlex",
+
]
+

+
[[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"
@@ -1616,6 +1668,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"
@@ -1862,6 +1920,15 @@ dependencies = [
]

[[package]]
+
name = "is_executable"
+
version = "1.0.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "d4a1b5bad6f9072935961dfbf1cced2f3d129963d091b6f69f007fe04e758ae2"
+
dependencies = [
+
 "winapi",
+
]
+

+
[[package]]
name = "itertools"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2720,6 +2787,8 @@ version = "0.16.0"
dependencies = [
 "anyhow",
 "chrono",
+
 "clap",
+
 "clap_complete",
 "dunce",
 "git-ref-format",
 "human-panic",
@@ -3639,6 +3708,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 = "subtle"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
modified crates/radicle-cli/Cargo.toml
@@ -17,6 +17,8 @@ path = "src/main.rs"
anyhow = "1"
chrono = { workspace = true, features = ["clock", "std"] }
dunce = { workspace = true }
+
clap = { version = "4.5.44", features = ["derive"] }
+
clap_complete = { version = "4.5.57", features = ["unstable-dynamic"] }
git-ref-format = { version = "0.3.0", features = ["macro"] }
human-panic.workspace = true
itertools.workspace = true
modified crates/radicle-cli/src/commands/help.rs
@@ -22,7 +22,6 @@ const COMMANDS: &[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,
modified crates/radicle-cli/src/commands/issue.rs
@@ -1,526 +1,128 @@
+
mod args;
mod cache;

-
use std::collections::BTreeSet;
-
use std::ffi::OsString;
-
use std::str::FromStr;
+
use std::sync::LazyLock;

-
use anyhow::{anyhow, Context as _};
+
use 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::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, 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)?;
-

-
                    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<()> {
+
const EXAMPLES: &[term::Example] = &[term::Example {
+
    args: &[
+
        "rad",
+
        "issue",
+
        "open",
+
        "--title",
+
        "\"My first issue\"",
+
        "--description",
+
        "\"Hello Radicle!\"",
+
    ],
+
}];
+

+
pub static HELP_EXAMPLES: LazyLock<String> = LazyLock::new(|| {
+
    let examples = EXAMPLES
+
        .iter()
+
        .map(|example| example.args.join(" "))
+
        .collect::<Vec<_>>()
+
        .join("\n");
+

+
    format!(
+
        "{}
+
  {}",
+
        term::HEADER_EXAMPLES,
+
        examples
+
    )
+
});
+

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

+
    let command = args.command.unwrap_or_default();
+
    let announce = !args.no_announce
        && matches!(
-
            &options.op,
-
            Operation::Open { .. }
-
                | Operation::React { .. }
-
                | Operation::State { .. }
-
                | Operation::Delete { .. }
-
                | Operation::Assign { .. }
-
                | Operation::Label { .. }
-
                | Operation::Edit { .. }
-
                | Operation::Comment { .. }
+
            &command,
+
            Command::Open { .. }
+
                | Command::React { .. }
+
                | Command::State { .. }
+
                | Command::Delete { .. }
+
                | Command::Assign { .. }
+
                | Command::Label { .. }
+
                | Command::Edit { .. }
+
                // TODO(erikli): Remove special handling for `--edit` and
+
                // make it also announce.
+
                | Command::Comment { edit: None, .. }
        );
+

    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 {
+
        Command::Comment {
            id,
            message,
            reply_to,
+
            edit: None,
        } => {
            let reply_to = reply_to
                .map(|rev| rev.resolve::<radicle::git::Oid>(repo.raw()))
@@ -529,23 +131,23 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
            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 {
+
            if args.quiet {
                term::print(comment_id);
            } else {
                let comment = issue.thread().comment(&comment_id).unwrap();
                term::comment::widget(&comment_id, comment, &profile).print();
            }
        }
-
        Operation::CommentEdit {
+
        Command::Comment {
            id,
-
            comment_id,
            message,
+
            reply_to: None,
+
            edit: Some(comment_id),
        } => {
            let signer = term::signer(&profile)?;
            let issue_id = id.resolve::<cob::ObjectId>(&repo.backend)?;
@@ -563,20 +165,26 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                comment.reply_to(),
                Some(comment.body()),
            )?;
+

            issue.edit_comment(comment_id, body, vec![], &signer)?;

-
            if options.quiet {
+
            if args.quiet {
                term::print(comment_id);
            } else {
                let comment = issue.thread().comment(&comment_id).unwrap();
                term::comment::widget(&comment_id, comment, &profile).print();
            }
        }
-
        Operation::Show {
-
            id,
-
            format,
-
            verbose,
-
        } => {
+
        Command::Comment { .. } => {
+
            unreachable!("the argument '--reply-to' cannot be used with '--edit'");
+
        }
+
        Command::Show { id } => {
+
            let format = if args.header {
+
                term::issue::Format::Header
+
            } else {
+
                term::issue::Format::Full
+
            };
+

            let id = id.resolve(&repo.backend)?;
            let issue = issues
                .get(&id)
@@ -585,14 +193,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 +215,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                };
            }
        }
-
        Operation::React {
+
        Command::React {
            id,
            reaction,
            comment_id,
@@ -613,7 +224,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 +235,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 +249,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 +260,19 @@ 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(list_args) => {
+
            let assigned = list_args.assigned.clone();
+
            list(issues, &assigned, &list_args.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 +307,7 @@ fn list<C>(
    assigned: &Option<Assigned>,
    state: &Option<State>,
    profile: &profile::Profile,
+
    verbose: bool,
) -> anyhow::Result<()>
where
    C: issue::cache::Issues,
@@ -786,7 +375,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 +386,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 +433,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 +459,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 +474,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)?;
@@ -935,7 +525,6 @@ pub fn prompt_comment(
    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);
@@ -981,23 +570,23 @@ pub fn prompt_comment(
                "\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)
@@ -1011,7 +600,24 @@ fn comment_quoted(comment: &thread::Comment, buffer: &mut String) {
        if !line.is_empty() {
            buffer.push(' ');
        }
+

        buffer.push_str(line);
        buffer.push('\n');
    }
}
+

+
#[cfg(test)]
+
mod tests {
+
    use super::*;
+
    use clap::Parser;
+

+
    #[test]
+
    fn examples_should_be_parsed() {
+
        for example in EXAMPLES.iter() {
+
            assert!(
+
                Args::try_parse_from(&example.args[1..]).is_ok(),
+
                "Invalid example in help text"
+
            );
+
        }
+
    }
+
}
added crates/radicle-cli/src/commands/issue/args.rs
@@ -0,0 +1,344 @@
+
#![warn(missing_docs)]
+
#![warn(clippy::missing_docs_in_private_items)]
+

+
//! Argument parsing for the `radicle-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 `NID`
+
    #[default]
+
    Me,
+
    /// Filter issues assigned to the given `DID`
+
    Peer(Did),
+
}
+

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

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

+
    /// Don't announce issue to peers
+
    #[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,
+
}
+

+
/// Commands to create, view, and edit Radicle issues
+
#[derive(Subcommand, Debug)]
+
pub(crate) enum Command {
+
    /// Manage assignees of an issue
+
    Assign {
+
        /// The issue to assign a DID to
+
        #[arg(value_name = "ISSUE_ID")]
+
        id: Rev,
+

+
        /// Add an assignee (may be specified multiple times)
+
        #[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 {
+
        /// The issue to comment on
+
        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>,
+

+
        /// The comment to edit (if any)
+
        #[arg(long, value_name = "COMMENT_ID")]
+
        #[arg(conflicts_with = "reply_to")]
+
        edit: Option<Rev>,
+
    },
+
    /// Edit an issue
+
    Edit {
+
        /// The issue to edit
+
        #[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 {
+
        /// The issue to delete
+
        #[arg(value_name = "ISSUE_ID")]
+
        id: Rev,
+
    },
+
    /// Update labels on an issue
+
    Label {
+
        /// The issue to label
+
        id: Rev,
+

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

+
        /// Delete an assignee (may be specified multiple times)
+
        ///
+
        /// Note: --add takes precedence over --delete
+
        #[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 new title to set
+
        #[arg(long, short)]
+
        title: Option<Title>,
+

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

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

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

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

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

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

+
impl Default for Command {
+
    fn default() -> Self {
+
        Self::List(ListArgs::default())
+
    }
+
}
+

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

+
    /// List all issues
+
    #[arg(long, group = "state")]
+
    all: bool,
+

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

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

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

+
impl Default for ListArgs {
+
    fn default() -> Self {
+
        Self {
+
            assigned: None,
+
            all: false,
+
            open: true,
+
            closed: false,
+
            solved: false,
+
        }
+
    }
+
}
+

+
impl From<ListArgs> for Option<State> {
+
    fn from(value: ListArgs) -> Self {
+
        if value.open {
+
            Some(State::Open)
+
        } else if value.closed {
+
            Some(State::Closed {
+
                reason: CloseReason::Other,
+
            })
+
        } else if value.solved {
+
            Some(State::Closed {
+
                reason: CloseReason::Solved,
+
            })
+
        } else {
+
            None
+
        }
+
    }
+
}
+

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

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

+
    /// Change 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))
+
        }
+
    }
+
}
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,30 @@ pub const VERSION: Version = Version {
    commit: GIT_HEAD,
    timestamp: TIMESTAMP,
};
+
const STYLES: Styles = Styles::styled()
+
    .header(AnsiColor::Yellow.on_default().bold())
+
    .usage(AnsiColor::Yellow.on_default().bold())
+
    .literal(AnsiColor::Cyan.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 {
+
    /// Manage issues by using operations to list, create or edit them.
+
    #[command(after_help = issue::HELP_EXAMPLES.as_str())]
+
    Issue(issue::Args),
+
}

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

+
/// Runs a `rad` command. `exe` expects the command's 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
+
/// therefor unknown command.
fn run_other(exe: &str, args: &[OsString]) -> Result<(), Option<anyhow::Error>> {
    match exe {
        "auth" => {
@@ -189,7 +226,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,12 +14,20 @@ pub mod upload_pack;
use std::ffi::OsString;
use std::process;

-
pub use radicle_term::*;
+
use clap::Parser;

use radicle::profile::{Home, Profile};
+
pub use radicle_term::*;

use crate::terminal;

+
pub const HEADER_EXAMPLES: &str = "\x1b[1;33mExamples:\x1b[0m";
+

+
#[derive(Debug)]
+
pub struct Example {
+
    pub(crate) args: &'static [&'static str],
+
}
+

/// Context passed to all commands.
pub trait Context {
    /// Return the currently active profile, or an error if no profile is active.
@@ -53,6 +61,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
@@ -112,6 +112,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
@@ -2662,3 +2662,28 @@ fn rad_workflow() {
    )
    .unwrap();
}
+

+
#[test]
+
fn issue_open_should_succeed() {
+
    use clap::Parser;
+
    use radicle_cli::commands::issue;
+

+
    let mut environment = Environment::new();
+
    let profile = environment.profile("alice");
+
    let working = environment.work(&profile);
+
    let (rid, _, _, _) =
+
        fixtures::project(working, &profile.storage, &profile.signer().unwrap()).unwrap();
+

+
    let args = issue::Args::parse_from(&[
+
        "issue",
+
        "open",
+
        "--title",
+
        "My first issue",
+
        "--description",
+
        "Hello World!",
+
        "--repo",
+
        &rid.to_string(),
+
    ]);
+

+
    assert!(issue::run(args, profile).is_ok());
+
}