Radish alpha
h
Radicle Heartwood Protocol & Stack
Radicle
Git (anonymous pull)
Log in to clone via SSH
Refactored the issue command using clap
Christopher Fredén committed 2 years ago
commit ef2949832b1a684526bce50cfdfea5db260a7d28
parent 92cbaa6d97ed7be579fc2e1da7ddff71595f117b
5 files changed +505 -558
modified Cargo.lock
@@ -528,7 +528,7 @@ dependencies = [

[[package]]
name = "clap"
-
version = "4.5.0"
+
version = "4.5.1"
dependencies = [
 "clap_builder",
 "clap_derive",
@@ -536,7 +536,7 @@ dependencies = [

[[package]]
name = "clap_builder"
-
version = "4.5.0"
+
version = "4.5.1"
dependencies = [
 "anstream",
 "anstyle",
@@ -546,7 +546,7 @@ dependencies = [

[[package]]
name = "clap_complete"
-
version = "4.5.0"
+
version = "4.5.1"
dependencies = [
 "clap",
 "clap_lex",
modified radicle-cli/src/cli.rs
@@ -1,69 +1,85 @@
use std::ffi::OsString;

-
use anyhow::anyhow;
-
use clap::{CommandFactory, FromArgMatches, Parser, Subcommand, ValueHint};
+
use clap::{Command, FromArgMatches, Subcommand};
use clap_complete::dynamic::shells::CompleteCommand;
+
use radicle::identity::Did;
use radicle::issue::Issues;
-
use radicle::storage::WriteStorage;
+
use radicle::storage::{ReadRepository, ReadStorage};

-
use crate::terminal as term;
-
use crate::commands::rad_issue;
-
use crate::git::Rev;
-

-
#[derive(Parser, Debug)]
-
#[command(name = "rad")]
-
#[command(about = "The rad CLI", long_about = None)]
-
struct CliArgs {
-
    #[command(subcommand)]
-
    command: Option<Commands>,
-
}
-

-
#[derive(Subcommand, Debug)]
-
enum Commands {
-
    Issue(IssueArgs),
-
}
-

-
#[derive(Parser, Debug)]
-
struct IssueArgs {
-
    #[command(subcommand)]
-
    command: Option<IssueCommands>,
+
pub fn get_assignee_did_hints(input: &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(input).then(|| String::from(did))
+
                            })
+
                            .collect::<Vec<_>>()
+
                    })
+
                    .ok()
+
            })
+
        })
}

-
#[derive(Subcommand, Debug)]
-
enum IssueCommands {
-
    List,
-
    Show {
-
        #[clap(value_hint = ISSUE_ID_HINT)]
-
        id: String,
-
    },
+
pub fn get_issue_id_hints(input: &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
+
                            .filter_map(|issue| {
+
                                if let Ok((id, _)) = issue {
+
                                    let id = id.to_string();
+
                                    if id.starts_with(input) {
+
                                        return Some(String::from(id.split_at(8).0));
+
                                    }
+
                                }
+
                                None
+
                            })
+
                            .collect::<Vec<_>>()
+
                    })
+
                    .ok()
+
            })
+
        })
}

-
const ISSUE_ID_HINT: ValueHint = ValueHint::Dynamic(get_issue_ids);
-

-
fn get_issue_ids(input: &str) -> Option<Vec<String>> {
-
    let profile = term::profile().ok()?;
-

+
pub fn get_did_hints<R: ReadRepository + radicle::cob::Store>(input: &str) -> Option<Vec<String>> {
    let (_, rid) = radicle::rad::cwd().ok()?;
-
    let repo = profile.storage.repository_mut(rid).ok()?;
-
    let issues = Issues::open(&repo).ok()?;
-

-
    let completions = issues.all().ok()?
-
        .filter_map(|issue| {
-
            if let Ok((id, _)) = issue {
-
                let id = id.to_string();
-
                if id.starts_with(input) {
-
                    return Some(String::from(id.split_at(8).0));
-
                }
-
            }
-
            None
+
    radicle::Profile::load()
+
        .ok()
+
        .and_then(|profile| profile.storage.repository(rid).ok())
+
        .and_then(|repo| {
+
            repo.remote_ids()
+
                .map(|issues| {
+
                    issues
+
                        .filter_map(|id| {
+
                            let id = id.map(|id| Did::from(id).to_human()).ok()?;
+
                            id.starts_with(input).then_some(id)
+
                        })
+
                        .collect::<Vec<_>>()
+
                })
+
                .ok()
        })
-
        .collect::<Vec<_>>();
-

-
    Some(completions)
}

-
pub fn completer(args: Vec<OsString>) -> () {
-
    let cmd = CliArgs::command();
+
pub fn completer(cmd: Command, args: Vec<OsString>) -> () {
    let mut cmd = CompleteCommand::augment_subcommands(cmd);
    let matches = cmd.clone().get_matches();

@@ -74,36 +90,84 @@ pub fn completer(args: Vec<OsString>) -> () {
    }
}

-
pub fn to_issue_options() -> anyhow::Result<rad_issue::Options> {
-
    let args = CliArgs::parse();
-

-
    let options = match args {
-
        CliArgs { command } => match command {
-
            Some(Commands::Issue(IssueArgs {
-
                command: subcommand,
-
            })) => match subcommand {
-
                Some(IssueCommands::List) => Some(rad_issue::Options {
-
                    op: rad_issue::Operation::List {
-
                        assigned: None,
-
                        state: None,
-
                    },
-
                    announce: true,
-
                    quiet: false,
-
                }),
-
                Some(IssueCommands::Show { id }) => Some(rad_issue::Options {
-
                    op: rad_issue::Operation::Show {
-
                        id: Rev::from(id),
-
                        format: term::issue::Format::Full,
-
                        debug: false,
-
                    },
-
                    announce: true,
-
                    quiet: false,
-
                }),
-
                _ => None,
-
            },
-
            _ => None,
-
        },
-
    };
-

-
    options.ok_or(anyhow!("Command not implemented FIXME!"))
-
}
+
// pub fn to_issue_options() -> anyhow::Result<rad_issue::Options> {
+
//     let args = CliArgs::parse();
+

+
//     // Default to List command.
+
//     // let command = args.command.unwrap_or(Commands::Issue(IssueArgs {
+
//     //     command: Some(IssueCommands::List(ListArgs {
+
//     //         assigned: None,
+
//     //         filter: ListFilter::All,
+
//     //     })),
+
//     //     header: false,
+
//     //     quiet: false,
+
//     //     no_announce: false,
+
//     // }));
+

+
//     let options = match args {
+
//         CliArgs { command } => match command {
+
//             Some(Commands::Issue(IssueArgs {
+
//                 command: subcommand,
+
//                 quiet,
+
//                 no_announce,
+
//                 header,
+
//             })) => match subcommand {
+
//                 Some(IssueCommands::List(args)) => Some(rad_issue::Options {
+
//                     op: rad_issue::Operation::List {
+
//                         assigned: args
+
//                             .assigned
+
//                             .map(|did| {
+
//                                 let did =
+
//                                     term::args::did(&OsString::from(format!("did:key:{did}")))?;
+
//                                 Ok::<Option<Assigned>, anyhow::Error>(Some(Assigned::Peer(did)))
+
//                             })
+
//                             .unwrap_or(Ok(None))?,
+
//                         state: match args.filter {
+
//                             ListFilter::All => None,
+
//                             ListFilter::Open => Some(radicle::cob::issue::State::Open),
+
//                             ListFilter::Closed => Some(State::Closed {
+
//                                 reason: CloseReason::Other,
+
//                             }),
+
//                             ListFilter::Solved => Some(State::Closed {
+
//                                 reason: CloseReason::Solved,
+
//                             }),
+
//                         },
+
//                     },
+
//                     announce: !no_announce,
+
//                     quiet: quiet,
+
//                 }),
+
//                 Some(IssueCommands::Show(args)) => Some(rad_issue::Options {
+
//                     op: rad_issue::Operation::Show {
+
//                         id: Rev::from(args.id),
+
//                         format: if header {
+
//                             term::issue::Format::Header
+
//                         } else {
+
//                             term::issue::Format::Full
+
//                         },
+
//                         debug: args.debug,
+
//                     },
+
//                     announce: !no_announce,
+
//                     quiet: quiet,
+
//                 }),
+
//                 // Default `issue` subcommand is `list`.
+
//                 _ => Some(rad_issue::Options {
+
//                     op: rad_issue::Operation::List {
+
//                         assigned: None,
+
//                         state: None,
+
//                     },
+
//                     announce: false,
+
//                     quiet: false,
+
//                 }),
+
//             },
+
//             _ => None,
+
//         },
+
//     };
+

+
//     let Some(option) = options else {
+
//         CliArgs::command().write_help(&mut std::io::stdout())?;
+
//         println!();
+
//         process::exit(0);
+
//     };
+

+
//     Ok(option)
+
// }
modified radicle-cli/src/commands/issue.rs
@@ -1,11 +1,8 @@
#[path = "issue/cache.rs"]
mod cache;

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

-
use anyhow::{anyhow, Context as _};
+
use anyhow::Context as _;
+
use clap::{Parser, Subcommand, ValueHint};

use radicle::cob::common::{Label, Reaction};
use radicle::cob::issue;
@@ -20,10 +17,11 @@ use radicle::storage::{ReadRepository, WriteRepository, WriteStorage};
use radicle::Profile;
use radicle::{cob, Node};

+
use crate::cli::{get_assignee_did_hints, get_issue_id_hints};
use crate::git::Rev;
use crate::node;
use crate::terminal as term;
-
use crate::terminal::args::{Args, Error, Help};
+
use crate::terminal::args::Help;
use crate::terminal::format::Author;
use crate::terminal::issue::Format;
use crate::terminal::patch::Message;
@@ -77,22 +75,6 @@ Options
"#,
};

-
#[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 {
@@ -101,500 +83,368 @@ pub enum Assigned {
    Peer(Did),
}

-
#[derive(Debug, PartialEq, Eq)]
-
pub enum Operation {
+
#[derive(Parser, Debug)]
+
pub struct IssueArgs {
+
    #[command(subcommand)]
+
    command: Option<IssueCommands>,
+

+
    /// Don't print anything
+
    #[arg(short, long)]
+
    quiet: bool,
+

+
    /// Don't announce issue to peers
+
    #[arg(long)]
+
    #[arg(value_name = "no-announce")]
+
    no_announce: bool,
+

+
    /// Show only the issue header, hiding the comments
+
    #[arg(long)]
+
    header: bool,
+

+
    #[arg(long, short)]
+
    repo: Option<RepoId>,
+
}
+

+
#[derive(Subcommand, Debug)]
+
enum IssueCommands {
+
    /// Delete an issue.
+
    Delete {
+
        #[arg(value_name = "issue-id")]
+
        #[clap(value_hint = ValueHint::Dynamic(get_issue_id_hints))]
+
        id: Rev,
+
    },
+

+
    /// Edit an issue.
    Edit {
+
        #[arg(value_name = "issue-id")]
+
        #[clap(value_hint = ValueHint::Dynamic(get_issue_id_hints))]
        id: Rev,
+

+
        #[arg(long, short)]
        title: Option<String>,
+

+
        #[arg(long, short)]
        description: Option<String>,
    },
+

+
    List {
+
        #[clap(value_hint = ValueHint::Dynamic(get_assignee_did_hints))]
+
        #[arg(long, name = "did")]
+
        assigned: Option<Did>,
+

+
        #[clap(value_enum, default_value_t=StateFilter::All)]
+
        state: StateFilter,
+
    },
+

    Open {
+
        #[arg(long, short)]
        title: Option<String>,
+

+
        #[arg(long, short)]
        description: Option<String>,
+

+
        #[arg(long)]
        labels: Vec<Label>,
+

+
        #[arg(long)]
        assignees: Vec<Did>,
    },
-
    Show {
-
        id: Rev,
-
        format: Format,
-
        debug: bool,
-
    },
-
    Comment {
-
        id: Rev,
-
        message: Message,
-
        reply_to: Option<Rev>,
-
    },
-
    State {
-
        id: Rev,
-
        state: State,
-
    },
-
    Delete {
-
        id: Rev,
-
    },
+

    React {
+
        #[arg(value_name = "issue-id")]
+
        #[clap(value_hint = ValueHint::Dynamic(get_issue_id_hints))]
        id: Rev,
+

+
        #[arg(long = "emoji")]
+
        #[arg(value_name = "char")]
        reaction: Reaction,
+

+
        #[arg(long = "to")]
+
        #[arg(value_name = "comment")]
+
        // TODO: Add dynamic hint for comment ids
        comment_id: Option<thread::CommentId>,
    },
+

+
    /// Manage assignees of an issue
    Assign {
+
        #[clap(value_hint = ValueHint::Dynamic(get_issue_id_hints))]
+
        #[arg(value_name = "issue-id")]
        id: Rev,
-
        opts: AssignOptions,
+

+
        /// Add an assignee to the issue (may be specified multiple times).
+
        #[arg(long, short)]
+
        #[arg(value_name = "did")]
+
        #[arg(action = clap::ArgAction::Append)]
+
        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)]
+
        delete: Vec<Did>,
    },
+

+
    /// Update lables on an issue.
    Label {
+
        /// The issue to label.
+
        #[arg(value_name = "issue-id")]
+
        #[clap(value_hint = ValueHint::Dynamic(get_issue_id_hints))]
        id: Rev,
-
        opts: LabelOptions,
+

+
        /// 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>,
    },
-
    List {
-
        assigned: Option<Assigned>,
-
        state: Option<State>,
+

+
    /// Add a comment to an issue.
+
    Comment {
+
        #[arg(value_name = "issue-id")]
+
        #[clap(value_hint = ValueHint::Dynamic(get_issue_id_hints))]
+
        id: Rev,
+

+
        /// Message text.
+
        #[arg(long, short)]
+
        #[arg(value_name = "message")]
+
        message: Message,
+

+
        #[arg(long, name = "comment-id")]
+
        reply_to: Option<Rev>,
+
    },
+

+
    Show {
+
        #[arg(value_name = "issue-id")]
+
        #[clap(value_hint = ValueHint::Dynamic(get_issue_id_hints))]
+
        id: Rev,
+

+
        /// Show the issue as Rust debug output
+
        #[arg(long)]
+
        debug: bool,
+
    },
+

+
    State {
+
        #[arg(value_name = "issue-id")]
+
        #[clap(value_hint = ValueHint::Dynamic(get_issue_id_hints))]
+
        id: Rev,
+

+
        #[clap(value_enum)]
+
        state: StateChoice,
    },
    Cache {
+
        #[arg(value_name = "issue-id")]
        id: Option<Rev>,
    },
}

-
#[derive(Debug, Default, PartialEq, Eq)]
-
pub struct AssignOptions {
-
    pub add: BTreeSet<Did>,
-
    pub delete: BTreeSet<Did>,
+
#[derive(clap::ValueEnum, Clone, Debug)]
+
enum StateFilter {
+
    All,
+
    Closed,
+
    Open,
+
    Solved,
}

-
#[derive(Debug, Default, PartialEq, Eq)]
-
pub struct LabelOptions {
-
    pub add: BTreeSet<Label>,
-
    pub delete: BTreeSet<Label>,
+
fn to_state_filter(list_state: StateFilter) -> Option<State> {
+
    match list_state {
+
        StateFilter::All => None,
+
        StateFilter::Open => Some(radicle::cob::issue::State::Open),
+
        StateFilter::Closed => Some(State::Closed {
+
            reason: CloseReason::Other,
+
        }),
+
        StateFilter::Solved => Some(State::Closed {
+
            reason: CloseReason::Solved,
+
        }),
+
    }
}

-
#[derive(Debug)]
-
pub struct Options {
-
    pub op: Operation,
-
    pub repo: Option<RepoId>,
-
    pub announce: bool,
-
    pub quiet: bool,
+
#[derive(clap::ValueEnum, Clone, Debug)]
+
enum StateChoice {
+
    Closed,
+
    Open,
+
    Solved,
}

-
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<String> = 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 announce = true;
-
        let mut quiet = false;
-
        let mut debug = false;
-
        let mut assign_opts = AssignOptions::default();
-
        let mut label_opts = LabelOptions::default();
-
        let mut repo = None;
-

-
        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 options.
-
                Long("title") if op == Some(OperationName::Open) => {
-
                    title = 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);
-
                }
-
                Long("description") if op == Some(OperationName::Open) => {
-
                    description = Some(parser.value()?.to_string_lossy().into());
-
                }
-

-
                // 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("debug") if op == Some(OperationName::Show) => {
-
                    debug = 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);
-
                }
-

-
                // 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);
-
                }
-

-
                // 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 => Operation::Comment {
-
                id: id.ok_or_else(|| anyhow!("an issue must be provided"))?,
-
                message,
-
                reply_to,
-
            },
-
            OperationName::Show => Operation::Show {
-
                id: id.ok_or_else(|| anyhow!("an issue must be provided"))?,
-
                format,
-
                debug,
-
            },
-
            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: reaction.ok_or_else(|| anyhow!("a reaction emoji must be provided"))?,
-
                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 },
-
        };
-

-
        Ok((
-
            Options {
-
                op,
-
                repo,
-
                announce,
-
                quiet,
-
            },
-
            vec![],
-
        ))
+
fn to_state_choice(choice: StateChoice) -> State {
+
    match choice {
+
        StateChoice::Open => radicle::cob::issue::State::Open,
+
        StateChoice::Closed => State::Closed {
+
            reason: CloseReason::Other,
+
        },
+
        StateChoice::Solved => State::Closed {
+
            reason: CloseReason::Solved,
+
        },
    }
}

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

    let mut issues = profile.issues_mut(&repo)?;

-
    match options.op {
-
        Operation::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, &profile)?;
+
    if let Some(command) = args.command {
+
        let announce = !args.no_announce
+
            && matches!(
+
                &command,
+
                IssueCommands::Open { .. }
+
                    | IssueCommands::React { .. }
+
                    | IssueCommands::State { .. }
+
                    | IssueCommands::Delete { .. }
+
                    | IssueCommands::Assign { .. }
+
                    | IssueCommands::Label { .. }
+
            );
+

+
        match command {
+
            IssueCommands::Delete { id } => {
+
                let signer = term::signer(&profile)?;
+
                let id = id.resolve(&repo.backend)?;
+
                issues.remove(&id, &signer)?;
            }
-
        }
-
        Operation::Open {
-
            title: Some(title),
-
            description: Some(description),
-
            labels,
-
            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, &profile)?;
+
            IssueCommands::Edit {
+
                id,
+
                title,
+
                description,
+
            } => {
+
                let signer = term::signer(&profile)?;
+
                let issue = edit(&mut issues, &repo, id, title, description, &signer)?;
+
                if !args.quiet {
+
                    term::issue::show(&issue, issue.id(), Format::Header, &profile)?;
+
                }
            }
-
        }
-
        Operation::Comment {
-
            id,
-
            message,
-
            reply_to,
-
        } => {
-
            let signer = term::signer(&profile)?;
-
            let issue_id = id.resolve::<cob::ObjectId>(&repo.backend)?;
-
            let mut issue = issues.get_mut(&issue_id)?;
-
            let (body, reply_to) = prompt_comment(message, reply_to, &issue, &repo)?;
-
            let comment_id = issue.comment(body, reply_to, vec![], &signer)?;
-

-
            if options.quiet {
-
                term::print(comment_id);
-
            } else {
-
                let comment = issue.thread().comment(&comment_id).unwrap();
-
                term::comment::widget(&comment_id, comment, &profile).print();
+
            IssueCommands::List { assigned, state } => {
+
                let assigned = assigned.map(Assigned::Peer);
+
                let state = to_state_filter(state);
+
                list(issues, &assigned, &state, &profile)?;
            }
-
        }
-
        Operation::Show { id, format, debug } => {
-
            let id = id.resolve(&repo.backend)?;
-
            let issue = issues
-
                .get(&id)?
-
                .context("No issue with the given ID exists")?;
-
            if debug {
-
                println!("{:#?}", issue);
-
            } else {
-
                term::issue::show(&issue, &id, format, &profile)?;
+
            IssueCommands::Show { id, debug } => {
+
                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)?
+
                    .context("No issue with the given ID exists")?;
+
                if debug {
+
                    println!("{:#?}", issue);
+
                } else {
+
                    term::issue::show(&issue, &id, format, &profile)?;
+
                }
            }
-
        }
-
        Operation::State { id, state } => {
-
            let signer = term::signer(&profile)?;
-
            let id = id.resolve(&repo.backend)?;
-
            let mut issue = issues.get_mut(&id)?;
-
            issue.lifecycle(state, &signer)?;
-
        }
-
        Operation::React {
-
            id,
-
            reaction,
-
            comment_id,
-
        } => {
-
            let id = id.resolve(&repo.backend)?;
-
            if let Ok(mut issue) = issues.get_mut(&id) {
+
            IssueCommands::State { id, state } => {
                let signer = term::signer(&profile)?;
-
                let comment_id = comment_id.unwrap_or_else(|| {
-
                    let (comment_id, _) = term::io::comment_select(&issue).unwrap();
-
                    *comment_id
-
                });
-
                issue.react(comment_id, reaction, true, &signer)?;
+
                let id = id.resolve(&repo.backend)?;
+
                let mut issue = issues.get_mut(&id)?;
+
                issue.lifecycle(to_state_choice(state), &signer)?;
+
            }
+
            IssueCommands::Assign { id, add, delete } => {
+
                let signer = term::signer(&profile)?;
+
                let id = id.resolve(&repo.backend)?;
+
                let Ok(mut issue) = issues.get_mut(&id) else {
+
                    anyhow::bail!("Issue `{id}` not found");
+
                };
+
                let assignees = issue
+
                    .assignees()
+
                    .filter(|did| !delete.contains(did))
+
                    .chain(add.iter())
+
                    .cloned()
+
                    .collect::<Vec<_>>();
+
                issue.assign(assignees, &signer)?;
+
            }
+
            IssueCommands::Comment {
+
                id,
+
                message,
+
                reply_to,
+
            } => {
+
                let signer = term::signer(&profile)?;
+
                let issue_id = id.resolve::<cob::ObjectId>(&repo.backend)?;
+
                let mut issue = issues.get_mut(&issue_id)?;
+
                let (body, reply_to) = prompt_comment(message, reply_to, &issue, &repo)?;
+
                let comment_id = issue.comment(body, reply_to, vec![], &signer)?;
+

+
                if args.quiet {
+
                    term::print(comment_id);
+
                } else {
+
                    let comment = issue.thread().comment(&comment_id).unwrap();
+
                    term::comment::widget(&comment_id, comment, &profile).print();
+
                }
+
            }
+
            IssueCommands::React {
+
                id,
+
                comment_id,
+
                reaction,
+
            } => {
+
                let signer = term::signer(&profile)?;
+
                let id = id.resolve(&repo.backend)?;
+
                if let Ok(mut issue) = issues.get_mut(&id) {
+
                    let comment_id = comment_id.unwrap_or_else(|| {
+
                        let (comment_id, _) = term::io::comment_select(&issue).unwrap();
+
                        *comment_id
+
                    });
+
                    issue.react(comment_id, reaction, true, &signer)?;
+
                }
+
            }
+
            IssueCommands::Label { id, add, delete } => {
+
                let signer = term::signer(&profile)?;
+
                let id = id.resolve(&repo.backend)?;
+
                let Ok(mut issue) = issues.get_mut(&id) else {
+
                    anyhow::bail!("Issue `{id}` not found");
+
                };
+
                let labels = issue
+
                    .labels()
+
                    .filter(|did| !delete.contains(did))
+
                    .chain(add.iter())
+
                    .cloned()
+
                    .collect::<Vec<_>>();
+
                issue.label(labels, &signer)?;
+
            }
+
            IssueCommands::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(),
+
                    args.quiet,
+
                    &mut issues,
+
                    &signer,
+
                    &profile,
+
                )?;
+
            }
+
            IssueCommands::Cache { id } => {
+
                let id = id.map(|id| id.resolve(&repo.backend)).transpose()?;
+
                cache::run(id, &repo, &profile)?;
            }
        }
-
        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 },
-
        } => {
-
            let signer = term::signer(&profile)?;
-
            let id = id.resolve(&repo.backend)?;
-
            let Ok(mut issue) = issues.get_mut(&id) else {
-
                anyhow::bail!("Issue `{id}` not found");
-
            };
-
            let assignees = issue
-
                .assignees()
-
                .filter(|did| !delete.contains(did))
-
                .chain(add.iter())
-
                .cloned()
-
                .collect::<Vec<_>>();
-
            issue.assign(assignees, &signer)?;
-
        }
-
        Operation::Label {
-
            id,
-
            opts: LabelOptions { add, delete },
-
        } => {
-
            let signer = term::signer(&profile)?;
-
            let id = id.resolve(&repo.backend)?;
-
            let Ok(mut issue) = issues.get_mut(&id) else {
-
                anyhow::bail!("Issue `{id}` not found");
-
            };
-
            let labels = issue
-
                .labels()
-
                .filter(|did| !delete.contains(did))
-
                .chain(add.iter())
-
                .cloned()
-
                .collect::<Vec<_>>();
-
            issue.label(labels, &signer)?;
-
        }
-
        Operation::List { assigned, state } => {
-
            list(issues, &assigned, &state, &profile)?;
-
        }
-
        Operation::Delete { id } => {
-
            let signer = term::signer(&profile)?;
-
            let id = id.resolve(&repo.backend)?;
-
            issues.remove(&id, &signer)?;
-
        }
-
        Operation::Cache { id } => {
-
            let id = id.map(|id| id.resolve(&repo.backend)).transpose()?;
-
            cache::run(id, &repo, &profile)?;
-
        }
-
    }

-
    if announce {
-
        let mut node = Node::new(profile.socket());
-
        node::announce(rid, &mut node)?;
-
    }
+
        if announce {
+
            let mut node = Node::new(profile.socket());
+
            node::announce(rid, &mut node)?;
+
        }
+
    } else {
+
        // Default `issue` subcommand is `list`.
+
        list(issues, &None, &None, &profile)?;
+
    };

    Ok(())
}
@@ -710,7 +560,7 @@ fn open<R, G>(
    description: Option<String>,
    labels: Vec<Label>,
    assignees: Vec<Did>,
-
    options: &Options,
+
    quiet: bool,
    cache: &mut issue::Cache<issue::Issues<'_, R>, cob::cache::StoreWriter>,
    signer: &G,
    profile: &Profile,
@@ -735,7 +585,7 @@ where
        signer,
    )?;

-
    if !options.quiet {
+
    if !quiet {
        term::issue::show(&issue, issue.id(), Format::Header, profile)?;
    }
    Ok(())
modified radicle-cli/src/main.rs
@@ -3,11 +3,13 @@ use std::io::{self, Write};
use std::{io::ErrorKind, iter, process};

use anyhow::anyhow;
+
use clap::{CommandFactory, Parser, Subcommand};

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

pub const NAME: &str = "rad";
pub const PKG_VERSION: &str = env!("CARGO_PKG_VERSION");
@@ -21,6 +23,19 @@ pub const VERSION: Version = Version {
    timestamp: TIMESTAMP,
};

+
#[derive(Parser, Debug)]
+
#[command(name = "rad")]
+
#[command(about = "The rad CLI", long_about = None)]
+
struct CliArgs {
+
    #[command(subcommand)]
+
    pub command: Option<Commands>,
+
}
+

+
#[derive(Subcommand, Debug)]
+
enum Commands {
+
    Issue(rad_issue::IssueArgs),
+
}
+

#[derive(Debug)]
enum Command {
    Other(Vec<OsString>),
@@ -226,11 +241,21 @@ fn run_other(exe: &str, args: &[OsString]) -> Result<(), Option<anyhow::Error>>
            );
        }
        "issue" => {
-
            let options = cli::to_issue_options()?;
-
            rad_issue::run(options, term::profile()?)?;
+
            let args_ = CliArgs::parse();
+
            if let Some(command) = args_.command {
+
                match command {
+
                    Commands::Issue(args_) => rad_issue::run(
+
                        args_,
+
                        radicle::Profile::load()
+
                            .map_err(|e| anyhow!(e))?,
+
                    )?,
+
                }
+
                // If clap parsed a command short circuit.
+
                // return Ok(());
+
            }
        }
        "complete" => {
-
            cli::completer(args.to_vec());
+
            cli::completer(CliArgs::command(), args.to_vec());
        }
        "ls" => {
            term::run_command_args::<rad_ls::Options, _>(rad_ls::HELP, rad_ls::run, args.to_vec());
modified radicle-cli/src/terminal/patch.rs
@@ -108,6 +108,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