Radish alpha
h
Radicle Heartwood Protocol & Stack
Radicle
Git (anonymous pull)
Log in to clone via SSH
cli/issue: Use clap
Christopher Fredén committed 7 months ago
commit b34a818cbbcd6e822324d6082170071e1be6597d
parent 0d17c8f4bac11abfd806dbfccedc1ee543ae25f5
6 files changed +613 -389
modified crates/radicle-cli/src/commands.rs
@@ -10,6 +10,7 @@ pub mod diff;
pub mod follow;
pub mod fork;
pub mod help;
+
pub mod hints;
pub mod id;
pub mod inbox;
pub mod init;
added crates/radicle-cli/src/commands/hints.rs
@@ -0,0 +1,92 @@
+
//! Provide auto-completion hints for CLI usage.
+

+
use clap_complete::CompletionCandidate;
+

+
use radicle::{
+
    identity::Did,
+
    issue::{cache::IssuesExt as _, Issues},
+
    storage::ReadStorage as _,
+
};
+

+
/// List the `DID`s associated with the current repository, and are assigned
+
/// to any issue, filtering by the `prefix`.
+
pub fn assignee_dids(prefix: &str) -> Option<Vec<String>> {
+
    let (_, rid) = radicle::rad::cwd().ok()?;
+
    radicle::Profile::load()
+
        .ok()
+
        .and_then(|profile| profile.storage.repository(rid).ok())
+
        .and_then(|repo| {
+
            Issues::open(&repo).ok().and_then(|issues| {
+
                issues
+
                    .all()
+
                    .map(|issues| {
+
                        issues
+
                            .flat_map(|issue| {
+
                                issue.map_or(vec![], |(_, issue)| {
+
                                    issue.assignees().cloned().collect::<Vec<_>>()
+
                                })
+
                            })
+
                            .filter_map(|did| {
+
                                let did = did.to_human();
+
                                did.starts_with(prefix).then_some(did)
+
                            })
+
                            .collect::<Vec<_>>()
+
                    })
+
                    .ok()
+
            })
+
        })
+
}
+

+
/// Wrapper for [`assignee_dids`] to support clap API.
+
pub(crate) fn assignee_dids_completer(current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
+
    let current = current.to_string_lossy();
+
    let result = assignee_dids(&current).unwrap_or_default();
+

+
    result.into_iter().map(CompletionCandidate::new).collect()
+
}
+

+
/// List the `IssueId`s associated with the current repository, filtered by the `prefix`.
+
pub fn issue_ids(prefix: &str) -> Option<Vec<String>> {
+
    let (_, rid) = radicle::rad::cwd().ok()?;
+
    let profile = radicle::Profile::load().ok()?;
+
    let repo = profile.storage.repository(rid).ok()?;
+
    let issues = profile.issues(&repo).ok()?;
+
    let ids = issues.ids(prefix).ok()?;
+
    Some(
+
        ids.filter_map(|result| result.ok().map(|id| id.to_string()))
+
            .collect(),
+
    )
+
}
+

+
/// Wrapper for [`issue_ids`] to support clap API.
+
pub(crate) fn issue_ids_completer(current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
+
    let current = current.to_string_lossy();
+
    let result = issue_ids(&current).unwrap_or_default();
+

+
    result.into_iter().map(CompletionCandidate::new).collect()
+
}
+

+
/// List the `DID`s associated with the current repository, filtered by the `prefix`.
+
// TODO: we could make this more like a fuzzy search
+
pub fn dids(prefix: &str) -> Option<Vec<String>> {
+
    let (_, rid) = radicle::rad::cwd().ok()?;
+
    let profile = radicle::Profile::load().ok()?;
+
    let repo = profile.storage.repository(rid).ok()?;
+
    let ids = repo.remote_ids().ok()?;
+
    Some(
+
        ids.filter_map(|nid| {
+
            let nid = nid.ok()?;
+
            let did = Did::from(nid).to_human();
+
            did.starts_with(prefix).then_some(did)
+
        })
+
        .collect(),
+
    )
+
}
+

+
/// Wrapper for [`dids`] to support clap API.
+
pub(crate) fn dids_completer(current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
+
    let current = current.to_string_lossy();
+
    let result = dids(&current).unwrap_or_default();
+

+
    result.into_iter().map(CompletionCandidate::new).collect()
+
}
modified crates/radicle-cli/src/commands/issue.rs
@@ -1,30 +1,34 @@
+
mod args;
mod cache;

use std::collections::BTreeSet;
-
use std::ffi::OsString;
-
use std::str::FromStr;

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

use radicle::cob::common::{Label, Reaction};
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::prelude::RepoId;
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, Help};
use crate::terminal::format::Author;
use crate::terminal::issue::Format;
use crate::terminal::patch::Message;
@@ -94,14 +98,6 @@ pub enum OperationName {
    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 {
@@ -180,347 +176,68 @@ pub struct Options {
    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<()> {
+
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 { .. }
+
                | Command::Comment { .. }
        );
+

    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 title = title.clone().map(|title| Title::try_from(title).unwrap()); // TODO: Properly use `Title`
            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().map(|title| Title::try_from(title).unwrap()), // TODO: Properly use `Title`
+
                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 +246,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 +280,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 { .. } => {
+
            todo!("We should use argument groups <https://docs.rs/clap/latest/clap/_derive/_tutorial/index.html#argument-relations> to ensure that comments can either be edited or composed (including a reply), but not both concurrently.")
+
        }
+
        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 +308,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 +330,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                };
            }
        }
-
        Operation::React {
+
        Command::React {
            id,
            reaction,
            comment_id,
@@ -624,28 +350,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 +364,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 +375,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 +422,7 @@ fn list<C>(
    assigned: &Option<Assigned>,
    state: &Option<State>,
    profile: &profile::Profile,
+
    verbose: bool,
) -> anyhow::Result<()>
where
    C: issue::cache::Issues,
@@ -786,7 +490,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 +501,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 +548,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 +574,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 +589,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 +640,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 +685,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,6 +715,7 @@ fn comment_quoted(comment: &thread::Comment, buffer: &mut String) {
        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,354 @@
+
#![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 clap_complete::ArgValueCompleter;
+
use radicle::{
+
    cob::{thread, Label, Reaction},
+
    identity::{did::DidError, Did, RepoId},
+
    issue::{CloseReason, State},
+
};
+

+
use crate::{commands::hints, 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)]
+
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,
+

+
    /// Optionally specify the repository to manage issues for
+
    #[arg(value_name = "RID")]
+
    #[arg(long, short)]
+
    #[clap(global = true)]
+
    pub(crate) repo: Option<RepoId>,
+

+
    /// 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 {
+
    /// Delete an issue
+
    Delete {
+
        /// The issue to delete
+
        #[arg(value_name = "ISSUE_ID", add = ArgValueCompleter::new(hints::issue_ids_completer))]
+
        id: Rev,
+
    },
+

+
    /// Edit an issue
+
    Edit {
+
        /// The issue to edit
+
        #[arg(value_name = "ISSUE_ID", add = ArgValueCompleter::new(hints::issue_ids_completer))]
+
        id: Rev,
+

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

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

+
    /// List issues, optionally filtering them
+
    List(ListArgs),
+

+
    /// Create a new issue
+
    Open {
+
        /// The new title of the issue
+
        #[arg(long, short)]
+
        title: Option<String>,
+

+
        /// The new 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 {
+
        /// The issue to react to
+
        #[arg(value_name = "ISSUE_ID", add = ArgValueCompleter::new(hints::issue_ids_completer))]
+
        id: Rev,
+

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

+
        /// Optionally react to a given comment in the issue
+
        #[arg(long = "to")]
+
        #[arg(value_name = "COMMENT_ID")]
+
        comment_id: Option<thread::CommentId>,
+
    },
+

+
    /// Manage assignees of an issue
+
    Assign {
+
        /// The issue to assign a DID to
+
        #[arg(value_name = "ISSUE_ID", add = ArgValueCompleter::new(hints::issue_ids_completer))]
+
        id: Rev,
+

+
        /// Add an assignee to the issue (may be specified multiple times)
+
        #[arg(long, short)]
+
        #[arg(value_name = "DID")]
+
        #[arg(action = clap::ArgAction::Append)]
+
        #[arg(add = ArgValueCompleter::new(hints::dids_completer))]
+
        add: Vec<Did>,
+

+
        /// Delete an assignee from the issue (may be specified multiple times)
+
        #[arg(long, short)]
+
        #[arg(value_name = "DID")]
+
        #[arg(action = clap::ArgAction::Append)]
+
        #[arg(add = ArgValueCompleter::new(hints::dids_completer))]
+
        delete: Vec<Did>,
+
    },
+

+
    /// Update labels on an issue
+
    Label {
+
        /// The issue to label
+
        #[arg(value_name = "ISSUE_ID", add = ArgValueCompleter::new(hints::issue_ids_completer))]
+
        id: Rev,
+

+
        /// Add an assignee to the issue (may be specified multiple times)
+
        #[arg(long, short)]
+
        #[arg(value_name = "label")]
+
        #[arg(action = clap::ArgAction::Append)]
+
        add: Vec<Label>,
+

+
        /// Delete an assignee from the issue (may be specified multiple times)
+
        #[arg(long, short)]
+
        #[arg(value_name = "label")]
+
        #[arg(action = clap::ArgAction::Append)]
+
        delete: Vec<Label>,
+
    },
+

+
    /// Add a comment to an issue.
+
    Comment {
+
        /// The issue to comment on
+
        #[arg(value_name = "ISSUE_ID", add = ArgValueCompleter::new(hints::issue_ids_completer))]
+
        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, name = "COMMENT_ID_TO_REPLY")]
+
        reply_to: Option<Rev>,
+

+
        /// The comment to edit (if any)
+
        #[arg(long, name = "COMMENT_ID_TO_EDIT")]
+
        edit: Option<Rev>,
+
    },
+

+
    /// Show a specific issue
+
    Show {
+
        /// The issue to display
+
        #[arg(value_name = "ISSUE_ID", add = ArgValueCompleter::new(hints::issue_ids_completer))]
+
        id: Rev,
+
    },
+

+
    /// 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,
+
    },
+

+
    /// Set the state of an issue
+
    State {
+
        /// The issue to be transitioned
+
        #[arg(value_name = "ISSUE_ID", add = ArgValueCompleter::new(hints::issue_ids_completer))]
+
        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)]
+
    #[arg(add = ArgValueCompleter::new(hints::assignee_dids_completer))]
+
    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,8 +3,13 @@ use std::io::{self, Write};
use std::{io::ErrorKind, iter, process};

use anyhow::anyhow;
+
use clap::builder::styling::Style;
+
use clap::builder::Styles;
+
use clap::{CommandFactory, Parser, Subcommand};
+
use clap_complete::Shell;

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

@@ -12,6 +17,7 @@ pub const NAME: &str = "rad";
pub const PKG_VERSION: &str = env!("CARGO_PKG_VERSION");
pub const RADICLE_VERSION: &str = env!("RADICLE_VERSION");
pub const DESCRIPTION: &str = "Radicle command line interface";
+
pub const LONG_DESCRIPTION: &str = "Radicle is a sovereign code forge built on Git.";
pub const GIT_HEAD: &str = env!("GIT_HEAD");
pub const TIMESTAMP: &str = env!("SOURCE_DATE_EPOCH");
pub const VERSION: Version = Version {
@@ -20,6 +26,40 @@ pub const VERSION: Version = Version {
    commit: GIT_HEAD,
    timestamp: TIMESTAMP,
};
+
pub const LONG_VERSION: &str = concat!(env!("CARGO_PKG_VERSION"), " (", env!("GIT_HEAD"), ")");
+
pub const HELP_TEMPLATE: &str = r#"
+
{before-help}{bin} {version}
+
{about-with-newline}
+
Usage: {usage}
+

+
{all-args}
+
{after-help}
+
"#;
+

+
/// Radicle command line interface
+
#[derive(Parser, Debug)]
+
#[command(name = NAME)]
+
#[command(version = PKG_VERSION)]
+
#[command(long_version = LONG_VERSION)]
+
#[command(help_template = HELP_TEMPLATE)]
+
#[command(propagate_version = true)]
+
#[command(styles = Styles::plain().literal(Style::new().bold()))]
+
struct CliArgs {
+
    #[command(subcommand)]
+
    pub command: Option<Commands>,
+

+
    #[arg(long = "generate")]
+
    #[clap(global = true)]
+
    pub(crate) generator: Option<clap_complete::Shell>,
+
}
+

+
#[derive(Subcommand, Debug)]
+
enum Commands {
+
    /// Commands to create, view, and edit Radicle issues
+
    ///
+
    /// With issues you can organize your project and use it to discuss bugs and improvements.
+
    Issue(rad_issue::Args),
+
}

#[derive(Debug)]
enum Command {
@@ -189,7 +229,31 @@ 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());
+
            // Use clap instead to parse all CLI args and ignore `args` passed
+
            // to `run_other`.
+
            let args_ = CliArgs::parse();
+
            if let Some(command) = args_.command {
+
                match command {
+
                    Commands::Issue(args_) => {
+
                        if let Err(err) =
+
                            issue::run(args_, radicle::Profile::load().map_err(|e| anyhow!(e))?)
+
                        {
+
                            radicle_cli::terminal::fail("", &err);
+
                            process::exit(1);
+
                        }
+
                    }
+
                }
+
            }
+
        }
+
        // Used for dynamic shell completion (not user facing)
+
        "complete" => {
+
            println!("parsing args");
+
            let args_ = CliArgs::parse_from(args);
+
            println!("have args");
+
            let generator = args_.generator.unwrap_or(Shell::Fish);
+
            let mut cmd = CliArgs::command();
+
            let bin_name = cmd.get_name().to_string();
+
            clap_complete::generate(generator, &mut cmd, bin_name, &mut io::stdout());
        }
        "ls" => {
            term::run_command_args::<ls::Options, _>(ls::HELP, ls::run, args.to_vec());
modified crates/radicle-cli/src/terminal/patch.rs
@@ -112,6 +112,14 @@ impl Message {
    }
}

+
impl From<String> for Message {
+
    fn from(value: String) -> Self {
+
        let mut message = Message::default();
+
        message.append(&value);
+
        message
+
    }
+
}
+

pub const PATCH_MSG: &str = r#"
<!--
Please enter a patch message for your changes. An empty