Radish alpha
h
rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
Radicle Heartwood Protocol & Stack
Radicle
Git
cli/patch: Use clap
Merged did:key:z6MkgFq6...nBGz opened 6 months ago

Add patch CLI parsing with clap

8 files changed +738 -967 e404f103 d1e19a87
modified crates/radicle-cli/src/commands/help.rs
@@ -79,7 +79,10 @@ const COMMANDS: &[CommandItem] = &[
        about: crate::commands::ls::ABOUT,
    },
    CommandItem::Lexopt(crate::commands::node::HELP),
-
    CommandItem::Lexopt(crate::commands::patch::HELP),
+
    CommandItem::Clap {
+
        name: "patch",
+
        about: crate::commands::patch::ABOUT,
+
    },
    CommandItem::Clap {
        name: "path",
        about: crate::commands::path::ABOUT,
modified crates/radicle-cli/src/commands/patch.rs
@@ -1,4 +1,5 @@
mod archive;
+
mod args;
mod assign;
mod cache;
mod checkout;
@@ -17,14 +18,11 @@ mod show;
mod update;

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

use anyhow::anyhow;

use radicle::cob::patch::PatchId;
-
use radicle::cob::{patch, Label, Reaction};
-
use radicle::git::fmt::RefString;
+
use radicle::cob::{patch, Label};
use radicle::patch::cache::Patches as _;
use radicle::storage::git::transport;
use radicle::{prelude::*, Node};
@@ -32,811 +30,15 @@ use radicle::{prelude::*, Node};
use crate::git::Rev;
use crate::node;
use crate::terminal as term;
-
use crate::terminal::args::{string, Args, Error, Help};
use crate::terminal::patch::Message;

-
pub const HELP: Help = Help {
-
    name: "patch",
-
    description: "Manage patches",
-
    version: env!("RADICLE_VERSION"),
-
    usage: r#"
-
Usage
+
pub use args::Args;
+
pub(crate) use args::ABOUT;

-
    rad patch [<option>...]
-
    rad patch list [--all|--merged|--open|--archived|--draft|--authored] [--author <did>]... [<option>...]
-
    rad patch show <patch-id> [<option>...]
-
    rad patch diff <patch-id> [<option>...]
-
    rad patch archive <patch-id> [--undo] [<option>...]
-
    rad patch update <patch-id> [<option>...]
-
    rad patch checkout <patch-id> [<option>...]
-
    rad patch review <patch-id> [--accept | --reject] [-m [<string>]] [-d | --delete] [<option>...]
-
    rad patch resolve <patch-id> [--review <review-id>] [--comment <comment-id>] [--unresolve] [<option>...]
-
    rad patch delete <patch-id> [<option>...]
-
    rad patch redact <revision-id> [<option>...]
-
    rad patch react <patch-id | revision-id> [--react <emoji>] [<option>...]
-
    rad patch assign <revision-id> [--add <did>] [--delete <did>] [<option>...]
-
    rad patch label <revision-id> [--add <label>] [--delete <label>] [<option>...]
-
    rad patch ready <patch-id> [--undo] [<option>...]
-
    rad patch edit <patch-id> [<option>...]
-
    rad patch set <patch-id> [<option>...]
-
    rad patch comment <patch-id | revision-id> [<option>...]
-
    rad patch cache [<patch-id>] [--storage] [<option>...]
+
use args::{Command, CommentAction};

-
Show options
-

-
    -p, --patch                Show the actual patch diff
-
    -v, --verbose              Show additional information about the patch
-

-
Diff options
-

-
    -r, --revision <id>        The revision to diff (default: latest)
-

-
Comment options
-

-
    -m, --message <string>     Provide a comment message via the command-line
-
        --reply-to <comment>   The comment to reply to
-
        --edit <comment>       The comment to edit (use --message to edit with the provided message)
-
        --react <comment>      The comment to react to
-
        --emoji <char>         The emoji to react with when --react is used
-
        --redact <comment>     The comment to redact
-

-
Edit options
-

-
    -m, --message [<string>]   Provide a comment message to the patch or revision (default: prompt)
-

-
Review options
-

-
    -r, --revision <id>        Review the given revision of the patch
-
    -p, --patch                Review by patch hunks
-
        --hunk <index>         Only review a specific hunk
-
        --accept               Accept a patch or set of hunks
-
        --reject               Reject a patch or set of hunks
-
    -U, --unified <n>          Generate diffs with <n> lines of context instead of the usual three
-
    -d, --delete               Delete a review draft
-
    -m, --message [<string>]   Provide a comment with the review (default: prompt)
-

-
Resolve options
-

-
    --review <id>              The review id which the comment is under
-
    --comment <id>             The comment to (un)resolve
-
    --undo                     Unresolve the comment
-

-
Assign options
-

-
    -a, --add    <did>         Add an assignee to the patch (may be specified multiple times).
-
                               Note: --add will take precedence over --delete
-

-
    -d, --delete <did>         Delete an assignee from the patch (may be specified multiple times).
-
                               Note: --add will take precedence over --delete
-

-
Archive options
-

-
        --undo                 Unarchive a patch
-

-
Label options
-

-
    -a, --add    <label>       Add a label to the patch (may be specified multiple times).
-
                               Note: --add will take precedence over --delete
-

-
    -d, --delete <label>       Delete a label from the patch (may be specified multiple times).
-
                               Note: --add will take precedence over --delete
-

-
Update options
-

-
    -b, --base <revspec>       Provide a Git revision as the base commit
-
    -m, --message [<string>]   Provide a comment message to the patch or revision (default: prompt)
-
        --no-message           Leave the patch or revision comment message blank
-

-
List options
-

-
        --all                  Show all patches, including merged and archived patches
-
        --archived             Show only archived patches
-
        --merged               Show only merged patches
-
        --open                 Show only open patches (default)
-
        --draft                Show only draft patches
-
        --authored             Show only patches that you have authored
-
        --author <did>         Show only patched where the given user is an author
-
                               (may be specified multiple times)
-

-
Ready options
-

-
        --undo                 Convert a patch back to a draft
-

-
Checkout options
-

-
        --revision <id>        Checkout the given revision of the patch
-
        --name <string>        Provide a name for the branch to checkout
-
        --remote <string>      Provide the git remote to use as the upstream
-
    -f, --force                Checkout the head of the revision, even if the branch already exists
-

-
Set options
-

-
        --remote <string>      Provide the git remote to use as the upstream
-

-
React options
-

-
        --emoji <char>         The emoji to react to the patch or revision with
-

-
Other options
-

-
        --repo <rid>           Operate on the given repository (default: cwd)
-
        --[no-]announce        Announce changes made to the network
-
    -q, --quiet                Quiet output
-
        --help                 Print help
-
"#,
-
};
-

-
#[derive(Debug, Default, PartialEq, Eq)]
-
pub enum OperationName {
-
    Assign,
-
    Show,
-
    Diff,
-
    Update,
-
    Archive,
-
    Delete,
-
    Checkout,
-
    Comment,
-
    React,
-
    Ready,
-
    Review,
-
    Resolve,
-
    Label,
-
    #[default]
-
    List,
-
    Edit,
-
    Redact,
-
    Set,
-
    Cache,
-
}
-

-
#[derive(Debug, PartialEq, Eq)]
-
pub enum CommentOperation {
-
    Edit,
-
    React,
-
    Redact,
-
}
-

-
#[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 enum Operation {
-
    Show {
-
        patch_id: Rev,
-
        diff: bool,
-
        verbose: bool,
-
    },
-
    Diff {
-
        patch_id: Rev,
-
        revision_id: Option<Rev>,
-
    },
-
    Update {
-
        patch_id: Rev,
-
        base_id: Option<Rev>,
-
        message: Message,
-
    },
-
    Archive {
-
        patch_id: Rev,
-
        undo: bool,
-
    },
-
    Ready {
-
        patch_id: Rev,
-
        undo: bool,
-
    },
-
    Delete {
-
        patch_id: Rev,
-
    },
-
    Checkout {
-
        patch_id: Rev,
-
        revision_id: Option<Rev>,
-
        opts: checkout::Options,
-
    },
-
    Comment {
-
        revision_id: Rev,
-
        message: Message,
-
        reply_to: Option<Rev>,
-
    },
-
    CommentEdit {
-
        revision_id: Rev,
-
        comment_id: Rev,
-
        message: Message,
-
    },
-
    CommentRedact {
-
        revision_id: Rev,
-
        comment_id: Rev,
-
    },
-
    CommentReact {
-
        revision_id: Rev,
-
        comment_id: Rev,
-
        reaction: Reaction,
-
        undo: bool,
-
    },
-
    React {
-
        revision_id: Rev,
-
        reaction: Reaction,
-
        undo: bool,
-
    },
-
    Review {
-
        patch_id: Rev,
-
        revision_id: Option<Rev>,
-
        opts: review::Options,
-
    },
-
    Resolve {
-
        patch_id: Rev,
-
        review_id: Rev,
-
        comment_id: Rev,
-
        undo: bool,
-
    },
-
    Assign {
-
        patch_id: Rev,
-
        opts: AssignOptions,
-
    },
-
    Label {
-
        patch_id: Rev,
-
        opts: LabelOptions,
-
    },
-
    List {
-
        filter: Option<patch::Status>,
-
    },
-
    Edit {
-
        patch_id: Rev,
-
        revision_id: Option<Rev>,
-
        message: Message,
-
    },
-
    Redact {
-
        revision_id: Rev,
-
    },
-
    Set {
-
        patch_id: Rev,
-
        remote: Option<RefString>,
-
    },
-
    Cache {
-
        patch_id: Option<Rev>,
-
        storage: bool,
-
    },
-
}
-

-
impl Operation {
-
    fn is_announce(&self) -> bool {
-
        match self {
-
            Operation::Update { .. }
-
            | Operation::Archive { .. }
-
            | Operation::Ready { .. }
-
            | Operation::Delete { .. }
-
            | Operation::Comment { .. }
-
            | Operation::CommentEdit { .. }
-
            | Operation::CommentRedact { .. }
-
            | Operation::CommentReact { .. }
-
            | Operation::Review { .. }
-
            | Operation::Resolve { .. }
-
            | Operation::Assign { .. }
-
            | Operation::Label { .. }
-
            | Operation::Edit { .. }
-
            | Operation::Redact { .. }
-
            | Operation::React { .. }
-
            | Operation::Set { .. } => true,
-
            Operation::Show { .. }
-
            | Operation::Diff { .. }
-
            | Operation::Checkout { .. }
-
            | Operation::List { .. }
-
            | Operation::Cache { .. } => false,
-
        }
-
    }
-
}
-

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

-
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 verbose = false;
-
        let mut quiet = false;
-
        let mut authored = false;
-
        let mut authors = vec![];
-
        let mut announce = true;
-
        let mut patch_id = None;
-
        let mut revision_id = None;
-
        let mut review_id = None;
-
        let mut comment_id = None;
-
        let mut message = Message::default();
-
        let mut filter = Some(patch::Status::Open);
-
        let mut diff = false;
-
        let mut undo = false;
-
        let mut reaction: Option<Reaction> = None;
-
        let mut reply_to: Option<Rev> = None;
-
        let mut comment_op: Option<(CommentOperation, Rev)> = None;
-
        let mut checkout_opts = checkout::Options::default();
-
        let mut remote: Option<RefString> = None;
-
        let mut assign_opts = AssignOptions::default();
-
        let mut label_opts = LabelOptions::default();
-
        let mut review_op = review::Operation::default();
-
        let mut base_id = None;
-
        let mut repo = None;
-
        let mut cache_storage = false;
-

-
        while let Some(arg) = parser.next()? {
-
            match arg {
-
                // Options.
-
                Long("message") | Short('m') => {
-
                    if message != Message::Blank {
-
                        // We skip this code when `no-message` is specified.
-
                        let txt: String = term::args::string(&parser.value()?);
-
                        message.append(&txt);
-
                    }
-
                }
-
                Long("no-message") => {
-
                    message = Message::Blank;
-
                }
-
                Long("announce") => {
-
                    announce = true;
-
                }
-
                Long("no-announce") => {
-
                    announce = false;
-
                }
-

-
                // Show options.
-
                Long("patch") | Short('p') if op == Some(OperationName::Show) => {
-
                    diff = true;
-
                }
-
                Long("verbose") | Short('v') if op == Some(OperationName::Show) => {
-
                    verbose = true;
-
                }
-

-
                // Ready options.
-
                Long("undo") if op == Some(OperationName::Ready) => {
-
                    undo = true;
-
                }
-

-
                // Archive options.
-
                Long("undo") if op == Some(OperationName::Archive) => {
-
                    undo = true;
-
                }
-

-
                // Update options.
-
                Short('b') | Long("base") if op == Some(OperationName::Update) => {
-
                    let val = parser.value()?;
-
                    let rev = term::args::rev(&val)?;
-

-
                    base_id = Some(rev);
-
                }
-

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

-
                // Comment options.
-
                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)?;
-

-
                    comment_op = Some((CommentOperation::Edit, rev));
-
                }
-

-
                Long("react") if op == Some(OperationName::Comment) => {
-
                    let val = parser.value()?;
-
                    let rev = term::args::rev(&val)?;
-

-
                    comment_op = Some((CommentOperation::React, rev));
-
                }
-
                Long("emoji")
-
                    if op == Some(OperationName::Comment)
-
                        && matches!(comment_op, Some((CommentOperation::React, _))) =>
-
                {
-
                    if let Some(emoji) = parser.value()?.to_str() {
-
                        reaction =
-
                            Some(Reaction::from_str(emoji).map_err(|_| anyhow!("invalid emoji"))?);
-
                    }
-
                }
-
                Long("undo")
-
                    if op == Some(OperationName::Comment)
-
                        && matches!(comment_op, Some((CommentOperation::React, _))) =>
-
                {
-
                    undo = true;
-
                }
-

-
                Long("redact") if op == Some(OperationName::Comment) => {
-
                    let val = parser.value()?;
-
                    let rev = term::args::rev(&val)?;
-

-
                    comment_op = Some((CommentOperation::Redact, rev));
-
                }
-

-
                // Edit options.
-
                Long("revision") | Short('r') if op == Some(OperationName::Edit) => {
-
                    let val = parser.value()?;
-
                    let rev = term::args::rev(&val)?;
-

-
                    revision_id = Some(rev);
-
                }
-

-
                // Review/diff options.
-
                Long("revision") | Short('r')
-
                    if op == Some(OperationName::Review) || op == Some(OperationName::Diff) =>
-
                {
-
                    let val = parser.value()?;
-
                    let rev = term::args::rev(&val)?;
-

-
                    revision_id = Some(rev);
-
                }
-
                Long("patch") | Short('p') if op == Some(OperationName::Review) => {
-
                    if let review::Operation::Review { by_hunk, .. } = &mut review_op {
-
                        *by_hunk = true;
-
                    } else {
-
                        return Err(arg.unexpected().into());
-
                    }
-
                }
-
                Long("unified") | Short('U') if op == Some(OperationName::Review) => {
-
                    if let review::Operation::Review { unified, .. } = &mut review_op {
-
                        let val = parser.value()?;
-
                        *unified = term::args::number(&val)?;
-
                    } else {
-
                        return Err(arg.unexpected().into());
-
                    }
-
                }
-
                Long("hunk") if op == Some(OperationName::Review) => {
-
                    if let review::Operation::Review { hunk, .. } = &mut review_op {
-
                        let val = parser.value()?;
-
                        let val = term::args::number(&val)
-
                            .map_err(|e| anyhow!("invalid hunk value: {e}"))?;
-

-
                        *hunk = Some(val);
-
                    } else {
-
                        return Err(arg.unexpected().into());
-
                    }
-
                }
-
                Long("delete") | Short('d') if op == Some(OperationName::Review) => {
-
                    review_op = review::Operation::Delete;
-
                }
-
                Long("accept") if op == Some(OperationName::Review) => {
-
                    if let review::Operation::Review {
-
                        verdict: verdict @ None,
-
                        ..
-
                    } = &mut review_op
-
                    {
-
                        *verdict = Some(patch::Verdict::Accept);
-
                    } else {
-
                        return Err(arg.unexpected().into());
-
                    }
-
                }
-
                Long("reject") if op == Some(OperationName::Review) => {
-
                    if let review::Operation::Review {
-
                        verdict: verdict @ None,
-
                        ..
-
                    } = &mut review_op
-
                    {
-
                        *verdict = Some(patch::Verdict::Reject);
-
                    } else {
-
                        return Err(arg.unexpected().into());
-
                    }
-
                }
-

-
                // Resolve options
-
                Long("undo") if op == Some(OperationName::Resolve) => {
-
                    undo = true;
-
                }
-
                Long("review") if op == Some(OperationName::Resolve) => {
-
                    let val = parser.value()?;
-
                    let rev = term::args::rev(&val)?;
-

-
                    review_id = Some(rev);
-
                }
-
                Long("comment") if op == Some(OperationName::Resolve) => {
-
                    let val = parser.value()?;
-
                    let rev = term::args::rev(&val)?;
-

-
                    comment_id = Some(rev);
-
                }
-

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

-
                    revision_id = Some(rev);
-
                }
-

-
                Long("force") | Short('f') if op == Some(OperationName::Checkout) => {
-
                    checkout_opts.force = true;
-
                }
-

-
                Long("name") if op == Some(OperationName::Checkout) => {
-
                    let val = parser.value()?;
-
                    checkout_opts.name = Some(term::args::refstring("name", val)?);
-
                }
-

-
                Long("remote") if op == Some(OperationName::Checkout) => {
-
                    let val = parser.value()?;
-
                    checkout_opts.remote = Some(term::args::refstring("remote", val)?);
-
                }
-

-
                // Assign options.
-
                Short('a') | Long("add") if matches!(op, Some(OperationName::Assign)) => {
-
                    assign_opts.add.insert(term::args::did(&parser.value()?)?);
-
                }
-

-
                Short('d') | Long("delete") if matches!(op, Some(OperationName::Assign)) => {
-
                    assign_opts
-
                        .delete
-
                        .insert(term::args::did(&parser.value()?)?);
-
                }
-

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

-
                // Set options.
-
                Long("remote") if op == Some(OperationName::Set) => {
-
                    let val = parser.value()?;
-
                    remote = Some(term::args::refstring("remote", val)?);
-
                }
-

-
                // List options.
-
                Long("all") => {
-
                    filter = None;
-
                }
-
                Long("draft") => {
-
                    filter = Some(patch::Status::Draft);
-
                }
-
                Long("archived") => {
-
                    filter = Some(patch::Status::Archived);
-
                }
-
                Long("merged") => {
-
                    filter = Some(patch::Status::Merged);
-
                }
-
                Long("open") => {
-
                    filter = Some(patch::Status::Open);
-
                }
-
                Long("authored") => {
-
                    authored = true;
-
                }
-
                Long("author") if op == Some(OperationName::List) => {
-
                    authors.push(term::args::did(&parser.value()?)?);
-
                }
-

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

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

-
                    repo = Some(rid);
-
                }
-
                Long("help") => {
-
                    return Err(Error::HelpManual { name: "rad-patch" }.into());
-
                }
-
                Short('h') => {
-
                    return Err(Error::Help.into());
-
                }
-

-
                Value(val) if op.is_none() => match val.to_string_lossy().as_ref() {
-
                    "l" | "list" => op = Some(OperationName::List),
-
                    "s" | "show" => op = Some(OperationName::Show),
-
                    "u" | "update" => op = Some(OperationName::Update),
-
                    "d" | "delete" => op = Some(OperationName::Delete),
-
                    "c" | "checkout" => op = Some(OperationName::Checkout),
-
                    "a" | "archive" => op = Some(OperationName::Archive),
-
                    "y" | "ready" => op = Some(OperationName::Ready),
-
                    "e" | "edit" => op = Some(OperationName::Edit),
-
                    "r" | "redact" => op = Some(OperationName::Redact),
-
                    "diff" => op = Some(OperationName::Diff),
-
                    "assign" => op = Some(OperationName::Assign),
-
                    "label" => op = Some(OperationName::Label),
-
                    "comment" => op = Some(OperationName::Comment),
-
                    "review" => op = Some(OperationName::Review),
-
                    "resolve" => op = Some(OperationName::Resolve),
-
                    "set" => op = Some(OperationName::Set),
-
                    "cache" => op = Some(OperationName::Cache),
-
                    unknown => anyhow::bail!("unknown operation '{}'", unknown),
-
                },
-
                Value(val) if op == Some(OperationName::Redact) => {
-
                    let rev = term::args::rev(&val)?;
-
                    revision_id = Some(rev);
-
                }
-
                Value(val)
-
                    if patch_id.is_none()
-
                        && [
-
                            Some(OperationName::Show),
-
                            Some(OperationName::Diff),
-
                            Some(OperationName::Update),
-
                            Some(OperationName::Delete),
-
                            Some(OperationName::Archive),
-
                            Some(OperationName::Ready),
-
                            Some(OperationName::Checkout),
-
                            Some(OperationName::Comment),
-
                            Some(OperationName::Review),
-
                            Some(OperationName::Resolve),
-
                            Some(OperationName::Edit),
-
                            Some(OperationName::Set),
-
                            Some(OperationName::Assign),
-
                            Some(OperationName::Label),
-
                            Some(OperationName::Cache),
-
                        ]
-
                        .contains(&op) =>
-
                {
-
                    let val = string(&val);
-
                    patch_id = Some(Rev::from(val));
-
                }
-
                _ => anyhow::bail!(arg.unexpected()),
-
            }
-
        }
-

-
        let op = match op.unwrap_or_default() {
-
            OperationName::List => Operation::List { filter },
-
            OperationName::Show => Operation::Show {
-
                patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?,
-
                diff,
-
                verbose,
-
            },
-
            OperationName::Diff => Operation::Diff {
-
                patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?,
-
                revision_id,
-
            },
-
            OperationName::Delete => Operation::Delete {
-
                patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?,
-
            },
-
            OperationName::Update => Operation::Update {
-
                patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?,
-
                base_id,
-
                message,
-
            },
-
            OperationName::Archive => Operation::Archive {
-
                patch_id: patch_id.ok_or_else(|| anyhow!("a patch id must be provided"))?,
-
                undo,
-
            },
-
            OperationName::Checkout => Operation::Checkout {
-
                patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?,
-
                revision_id,
-
                opts: checkout_opts,
-
            },
-
            OperationName::Comment => match comment_op {
-
                Some((CommentOperation::Edit, comment)) => Operation::CommentEdit {
-
                    revision_id: patch_id
-
                        .ok_or_else(|| anyhow!("a patch or revision must be provided"))?,
-
                    comment_id: comment,
-
                    message,
-
                },
-
                Some((CommentOperation::React, comment)) => Operation::CommentReact {
-
                    revision_id: patch_id
-
                        .ok_or_else(|| anyhow!("a patch or revision must be provided"))?,
-
                    comment_id: comment,
-
                    reaction: reaction
-
                        .ok_or_else(|| anyhow!("a reaction emoji must be provided"))?,
-
                    undo,
-
                },
-
                Some((CommentOperation::Redact, comment)) => Operation::CommentRedact {
-
                    revision_id: patch_id
-
                        .ok_or_else(|| anyhow!("a patch or revision must be provided"))?,
-
                    comment_id: comment,
-
                },
-
                None => Operation::Comment {
-
                    revision_id: patch_id
-
                        .ok_or_else(|| anyhow!("a patch or revision must be provided"))?,
-
                    message,
-
                    reply_to,
-
                },
-
            },
-
            OperationName::React => Operation::React {
-
                revision_id: patch_id
-
                    .ok_or_else(|| anyhow!("a patch or revision must be provided"))?,
-
                reaction: reaction.ok_or_else(|| anyhow!("a reaction emoji must be provided"))?,
-
                undo,
-
            },
-
            OperationName::Review => Operation::Review {
-
                patch_id: patch_id
-
                    .ok_or_else(|| anyhow!("a patch or revision must be provided"))?,
-
                revision_id,
-
                opts: review::Options {
-
                    message,
-
                    op: review_op,
-
                },
-
            },
-
            OperationName::Resolve => Operation::Resolve {
-
                patch_id: patch_id
-
                    .ok_or_else(|| anyhow!("a patch or revision must be provided"))?,
-
                review_id: review_id.ok_or_else(|| anyhow!("a review must be provided"))?,
-
                comment_id: comment_id.ok_or_else(|| anyhow!("a comment must be provided"))?,
-
                undo,
-
            },
-
            OperationName::Ready => Operation::Ready {
-
                patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?,
-
                undo,
-
            },
-
            OperationName::Edit => Operation::Edit {
-
                patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?,
-
                revision_id,
-
                message,
-
            },
-
            OperationName::Redact => Operation::Redact {
-
                revision_id: revision_id.ok_or_else(|| anyhow!("a revision must be provided"))?,
-
            },
-
            OperationName::Assign => Operation::Assign {
-
                patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?,
-
                opts: assign_opts,
-
            },
-
            OperationName::Label => Operation::Label {
-
                patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?,
-
                opts: label_opts,
-
            },
-
            OperationName::Set => Operation::Set {
-
                patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?,
-
                remote,
-
            },
-
            OperationName::Cache => Operation::Cache {
-
                patch_id,
-
                storage: cache_storage,
-
            },
-
        };
-

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

-
pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
-
    let (workdir, rid) = if let Some(rid) = options.repo {
+
pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
+
    let (workdir, rid) = if let Some(rid) = args.repo {
        (None, rid)
    } else {
        radicle::rad::cwd()
@@ -846,51 +48,57 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {

    let profile = ctx.profile()?;
    let repository = profile.storage.repository(rid)?;
-
    let announce = options.announce && options.op.is_announce();
+

+
    // Fallback to [`Command::List`] if no subcommand is provided.
+
    // Construct it using the [`EmptyArgs`] in `args.empty`.
+
    let command = args
+
        .command
+
        .unwrap_or_else(|| Command::List(args.empty.into()));
+
    let announce = !args.no_announce && command.should_announce();

    transport::local::register(profile.storage.clone());

-
    match options.op {
-
        Operation::List { filter } => {
-
            let mut authors: BTreeSet<Did> = options.authors.iter().cloned().collect();
-
            if options.authored {
+
    match command {
+
        Command::List(list_args) => {
+
            let mut authors: BTreeSet<Did> = list_args.authors.iter().cloned().collect();
+
            if list_args.authored {
                authors.insert(profile.did());
            }
-
            list::run(filter.as_ref(), authors, &repository, &profile)?;
+
            list::run((&list_args.state).into(), authors, &repository, &profile)?;
        }
-
        Operation::Show {
-
            patch_id,
-
            diff,
-
            verbose,
-
        } => {
-
            let patch_id = patch_id.resolve(&repository.backend)?;
+

+
        Command::Show { id, patch, verbose } => {
+
            let patch_id = id.resolve(&repository.backend)?;
            show::run(
                &patch_id,
-
                diff,
+
                patch,
                verbose,
                &profile,
                &repository,
                workdir.as_ref(),
            )?;
        }
-
        Operation::Diff {
-
            patch_id,
-
            revision_id,
-
        } => {
-
            let patch_id = patch_id.resolve(&repository.backend)?;
-
            let revision_id = revision_id
+

+
        Command::Diff { id, revision } => {
+
            let patch_id = id.resolve(&repository.backend)?;
+
            let revision_id = revision
                .map(|rev| rev.resolve::<radicle::git::Oid>(&repository.backend))
                .transpose()?
                .map(patch::RevisionId::from);
            diff::run(&patch_id, revision_id, &repository, &profile)?;
        }
-
        Operation::Update {
-
            ref patch_id,
-
            ref base_id,
-
            ref message,
+

+
        Command::Update {
+
            id,
+
            base,
+
            message:
+
                args::MessageArgs {
+
                    message,
+
                    no_message: _,
+
                },
        } => {
-
            let patch_id = patch_id.resolve(&repository.backend)?;
-
            let base_id = base_id
+
            let patch_id = id.resolve(&repository.backend)?;
+
            let base_id = base
                .as_ref()
                .map(|base| base.resolve(&repository.backend))
                .transpose()?;
@@ -907,12 +115,14 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                &workdir,
            )?;
        }
-
        Operation::Archive { ref patch_id, undo } => {
-
            let patch_id = patch_id.resolve::<PatchId>(&repository.backend)?;
+

+
        Command::Archive { id, undo } => {
+
            let patch_id = id.resolve::<PatchId>(&repository.backend)?;
            archive::run(&patch_id, undo, &profile, &repository)?;
        }
-
        Operation::Ready { ref patch_id, undo } => {
-
            let patch_id = patch_id.resolve::<PatchId>(&repository.backend)?;
+

+
        Command::Ready { id, undo } => {
+
            let patch_id = id.resolve::<PatchId>(&repository.backend)?;

            if !ready::run(&patch_id, undo, &profile, &repository)? {
                if undo {
@@ -922,17 +132,15 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                }
            }
        }
-
        Operation::Delete { patch_id } => {
-
            let patch_id = patch_id.resolve::<PatchId>(&repository.backend)?;
+

+
        Command::Delete { id } => {
+
            let patch_id = id.resolve::<PatchId>(&repository.backend)?;
            delete::run(&patch_id, &profile, &repository)?;
        }
-
        Operation::Checkout {
-
            patch_id,
-
            revision_id,
-
            opts,
-
        } => {
-
            let patch_id = patch_id.resolve::<radicle::git::Oid>(&repository.backend)?;
-
            let revision_id = revision_id
+

+
        Command::Checkout { id, revision, opts } => {
+
            let patch_id = id.resolve::<radicle::git::Oid>(&repository.backend)?;
+
            let revision_id = revision
                .map(|rev| rev.resolve::<radicle::git::Oid>(&repository.backend))
                .transpose()?
                .map(patch::RevisionId::from);
@@ -945,86 +153,72 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                &repository,
                &workdir,
                &profile,
-
                opts,
-
            )?;
-
        }
-
        Operation::Comment {
-
            revision_id,
-
            message,
-
            reply_to,
-
        } => {
-
            comment::run(
-
                revision_id,
-
                message,
-
                reply_to,
-
                options.quiet,
-
                &repository,
-
                &profile,
+
                opts.into(),
            )?;
        }
-
        Operation::Review {
-
            patch_id,
-
            revision_id,
-
            opts,
+

+
        Command::Review {
+
            id,
+
            revision,
+
            options,
        } => {
-
            let patch_id = patch_id.resolve(&repository.backend)?;
-
            let revision_id = revision_id
+
            let patch_id = id.resolve(&repository.backend)?;
+
            let revision_id = revision
                .map(|rev| rev.resolve::<radicle::git::Oid>(&repository.backend))
                .transpose()?
                .map(patch::RevisionId::from);
-
            review::run(patch_id, revision_id, opts, &profile, &repository)?;
+
            review::run(patch_id, revision_id, options.into(), &profile, &repository)?;
        }
-
        Operation::Resolve {
-
            ref patch_id,
-
            ref review_id,
-
            ref comment_id,
-
            undo,
+

+
        Command::Resolve {
+
            id,
+
            review,
+
            comment,
+
            unresolve,
        } => {
-
            let patch = patch_id.resolve(&repository.backend)?;
+
            let patch = id.resolve(&repository.backend)?;
            let review = patch::ReviewId::from(
-
                review_id.resolve::<radicle::cob::EntryId>(&repository.backend)?,
+
                review.resolve::<radicle::cob::EntryId>(&repository.backend)?,
            );
-
            let comment = comment_id.resolve(&repository.backend)?;
-
            if undo {
+
            let comment = comment.resolve(&repository.backend)?;
+
            if unresolve {
                resolve::unresolve(patch, review, comment, &repository, &profile)?;
-
                term::success!("Unresolved comment {comment_id}");
+
                term::success!("Unresolved comment {comment}");
            } else {
                resolve::resolve(patch, review, comment, &repository, &profile)?;
-
                term::success!("Resolved comment {comment_id}");
+
                term::success!("Resolved comment {comment}");
            }
        }
-
        Operation::Edit {
-
            patch_id,
-
            revision_id,
+
        Command::Edit {
+
            id,
+
            revision,
            message,
        } => {
-
            let patch_id = patch_id.resolve(&repository.backend)?;
-
            let revision_id = revision_id
+
            let patch_id = id.resolve(&repository.backend)?;
+
            let revision_id = revision
                .map(|id| id.resolve::<radicle::git::Oid>(&repository.backend))
                .transpose()?
                .map(patch::RevisionId::from);
+
            let message = message.iter().fold(Message::default(), |mut msg, line| {
+
                msg.append(&line);
+
                msg
+
            });
            edit::run(&patch_id, revision_id, message, &profile, &repository)?;
        }
-
        Operation::Redact { revision_id } => {
-
            redact::run(&revision_id, &profile, &repository)?;
+
        Command::Redact { id } => {
+
            redact::run(&id, &profile, &repository)?;
        }
-
        Operation::Assign {
-
            patch_id,
-
            opts: AssignOptions { add, delete },
-
        } => {
-
            let patch_id = patch_id.resolve(&repository.backend)?;
+
        Command::Assign { id, add, delete } => {
+
            let patch_id = id.resolve(&repository.backend)?;
            assign::run(&patch_id, add, delete, &profile, &repository)?;
        }
-
        Operation::Label {
-
            patch_id,
-
            opts: LabelOptions { add, delete },
-
        } => {
-
            let patch_id = patch_id.resolve(&repository.backend)?;
+
        Command::Label { id, add, delete } => {
+
            let patch_id = id.resolve(&repository.backend)?;
            label::run(&patch_id, add, delete, &profile, &repository)?;
        }
-
        Operation::Set { patch_id, remote } => {
+
        Command::Set { id, remote } => {
            let patches = term::cob::patches(&profile, &repository)?;
-
            let patch_id = patch_id.resolve(&repository.backend)?;
+
            let patch_id = id.resolve(&repository.backend)?;
            let patch = patches
                .get(&patch_id)?
                .ok_or_else(|| anyhow!("patch {patch_id} not found"))?;
@@ -1039,13 +233,11 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                true,
            )?;
        }
-
        Operation::Cache { patch_id, storage } => {
+
        Command::Cache { id, storage } => {
            let mode = if storage {
                cache::CacheMode::Storage
            } else {
-
                let patch_id = patch_id
-
                    .map(|id| id.resolve(&repository.backend))
-
                    .transpose()?;
+
                let patch_id = id.map(|id| id.resolve(&repository.backend)).transpose()?;
                patch_id.map_or(
                    cache::CacheMode::Repository {
                        repository: &repository,
@@ -1058,50 +250,59 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
            };
            cache::run(mode, &profile)?;
        }
-
        Operation::CommentEdit {
-
            revision_id,
-
            comment_id,
-
            message,
-
        } => {
-
            let comment = comment_id.resolve(&repository.backend)?;
-
            comment::edit::run(
-
                revision_id,
+
        Command::Comment(c) => match CommentAction::from(c) {
+
            CommentAction::Comment {
+
                revision,
+
                message,
+
                reply_to,
+
            } => {
+
                comment::run(
+
                    revision,
+
                    message,
+
                    reply_to,
+
                    args.quiet,
+
                    &repository,
+
                    &profile,
+
                )?;
+
            }
+
            CommentAction::Edit {
+
                revision,
                comment,
                message,
-
                options.quiet,
-
                &repository,
-
                &profile,
-
            )?;
-
        }
-
        Operation::CommentRedact {
-
            revision_id,
-
            comment_id,
-
        } => {
-
            let comment = comment_id.resolve(&repository.backend)?;
-
            comment::redact::run(revision_id, comment, &repository, &profile)?;
-
        }
-
        Operation::CommentReact {
-
            revision_id,
-
            comment_id,
-
            reaction,
-
            undo,
-
        } => {
-
            let comment = comment_id.resolve(&repository.backend)?;
-
            if undo {
-
                comment::react::run(revision_id, comment, reaction, false, &repository, &profile)?;
-
            } else {
-
                comment::react::run(revision_id, comment, reaction, true, &repository, &profile)?;
+
            } => {
+
                let comment = comment.resolve(&repository.backend)?;
+
                comment::edit::run(
+
                    revision,
+
                    comment,
+
                    message,
+
                    args.quiet,
+
                    &repository,
+
                    &profile,
+
                )?;
            }
-
        }
-
        Operation::React {
-
            revision_id,
-
            reaction,
-
            undo,
-
        } => {
+
            CommentAction::Redact { revision, comment } => {
+
                let comment = comment.resolve(&repository.backend)?;
+
                comment::redact::run(revision, comment, &repository, &profile)?;
+
            }
+
            CommentAction::React {
+
                revision,
+
                comment,
+
                emoji,
+
                undo,
+
            } => {
+
                let comment = comment.resolve(&repository.backend)?;
+
                if undo {
+
                    comment::react::run(revision, comment, emoji, false, &repository, &profile)?;
+
                } else {
+
                    comment::react::run(revision, comment, emoji, true, &repository, &profile)?;
+
                }
+
            }
+
        },
+
        Command::React { id, react, undo } => {
            if undo {
-
                react::run(&revision_id, reaction, false, &repository, &profile)?;
+
                react::run(&id, react, false, &repository, &profile)?;
            } else {
-
                react::run(&revision_id, reaction, true, &repository, &profile)?;
+
                react::run(&id, react, true, &repository, &profile)?;
            }
        }
    }
added crates/radicle-cli/src/commands/patch/args.rs
@@ -0,0 +1,560 @@
+
use clap::{Parser, Subcommand};
+

+
use radicle::cob::Label;
+
use radicle::git;
+
use radicle::git::fmt::RefString;
+
use radicle::patch::Status;
+
use radicle::patch::Verdict;
+
use radicle::prelude::Did;
+
use radicle::prelude::RepoId;
+

+
use crate::commands::patch::checkout;
+
use crate::commands::patch::review;
+

+
use crate::git::Rev;
+
use crate::terminal::patch::Message;
+

+
pub const ABOUT: &str = "Manage patches";
+

+
#[derive(Debug, Parser)]
+
#[command(about = ABOUT, disable_version_flag = true)]
+
pub struct Args {
+
    /// Subcommand for `rad patch`
+
    #[command(subcommand)]
+
    pub(super) command: Option<Command>,
+

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

+
    /// Do not announce to peers
+
    #[arg(long, global = true, conflicts_with = "no_announce")]
+
    pub(super) announce: bool,
+

+
    /// Do not announce to peers
+
    #[arg(long, global = true, conflicts_with = "announce")]
+
    pub(super) no_announce: bool,
+

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

+
    /// Optionally specify the repository to operate on
+
    #[arg(long, global = true, value_name = "RID")]
+
    pub(super) repo: Option<RepoId>,
+

+
    /// Verbose output
+
    #[arg(long, short, global = true)]
+
    pub(super) verbose: bool,
+

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

+
/// Commands to create, view, and edit Radicle issues
+
#[derive(Subcommand, Debug)]
+
pub(super) enum Command {
+
    #[command(alias = "l")]
+
    List(ListArgs),
+

+
    #[command(alias = "s")]
+
    Show {
+
        #[arg(value_name = "PATCH_ID")]
+
        id: Rev,
+

+
        #[arg(long, short)]
+
        patch: bool,
+

+
        #[arg(long, short)]
+
        verbose: bool,
+
    },
+

+
    Diff {
+
        #[arg(value_name = "PATCH_ID")]
+
        id: Rev,
+

+
        #[arg(long, short)]
+
        revision: Option<Rev>,
+
    },
+

+
    #[command(alias = "a")]
+
    Archive {
+
        #[arg(value_name = "PATCH_ID")]
+
        id: Rev,
+

+
        #[arg(long)]
+
        undo: bool,
+
    },
+

+
    #[command(alias = "u")]
+
    Update {
+
        #[arg(value_name = "PATCH_ID")]
+
        id: Rev,
+

+
        #[arg(long, short)]
+
        base: Option<Rev>,
+

+
        #[clap(flatten)]
+
        message: MessageArgs,
+
    },
+

+
    #[command(alias = "c")]
+
    Checkout {
+
        #[arg(value_name = "PATCH_ID")]
+
        id: Rev,
+

+
        #[arg(long)]
+
        revision: Option<Rev>,
+

+
        #[clap(flatten)]
+
        opts: CheckoutArgs,
+
    },
+

+
    Review {
+
        #[arg(value_name = "PATCH_ID")]
+
        id: Rev,
+

+
        #[arg(long, short)]
+
        revision: Option<Rev>,
+

+
        #[clap(flatten)]
+
        options: ReviewArgs,
+
    },
+

+
    Resolve {
+
        #[arg(value_name = "PATCH_ID")]
+
        id: Rev,
+

+
        #[arg(long, value_name = "REVIEW_ID")]
+
        review: Rev,
+

+
        #[arg(long, value_name = "COMMENT_ID")]
+
        comment: Rev,
+

+
        #[arg(long)]
+
        unresolve: bool,
+
    },
+

+
    #[command(alias = "d")]
+
    Delete {
+
        #[arg(value_name = "PATCH_ID")]
+
        id: Rev,
+
    },
+

+
    #[command(alias = "r")]
+
    Redact {
+
        #[arg(value_name = "PATCH_ID|REVISION_ID")]
+
        id: Rev,
+
    },
+

+
    React {
+
        #[arg(value_name = "PATCH_ID|REVISION_ID")]
+
        id: Rev,
+

+
        #[arg(long, value_name = "EMOJI")]
+
        react: radicle::cob::Reaction,
+

+
        #[arg(long)]
+
        undo: bool,
+
    },
+

+
    Assign {
+
        #[arg(value_name = "PATCH_ID")]
+
        id: Rev,
+

+
        #[arg(long, short, value_name = "DID", num_args = 1.., action = clap::ArgAction::Append)]
+
        add: Vec<Did>,
+

+
        #[clap(long, short, value_name = "DID", num_args = 1.., action = clap::ArgAction::Append)]
+
        delete: Vec<Did>,
+
    },
+

+
    Label {
+
        #[arg(value_name = "PATCH_ID")]
+
        id: Rev,
+

+
        #[arg(long, short, value_name = "LABEL", num_args = 1.., action = clap::ArgAction::Append)]
+
        add: Vec<Label>,
+

+
        #[clap(long, short, value_name = "LABEL", num_args = 1.., action = clap::ArgAction::Append)]
+
        delete: Vec<Label>,
+
    },
+

+
    #[command(alias = "y")]
+
    Ready {
+
        #[arg(value_name = "PATCH_ID")]
+
        id: Rev,
+

+
        #[arg(long)]
+
        undo: bool,
+
    },
+

+
    #[command(alias = "e")]
+
    Edit {
+
        #[arg(value_name = "PATCH_ID")]
+
        id: Rev,
+

+
        #[arg(long, value_name = "REVISION_ID")]
+
        revision: Option<Rev>,
+

+
        #[clap(long, short, value_name = "MESSAGE", num_args = 1.., action = clap::ArgAction::Append)]
+
        message: Vec<String>,
+
    },
+

+
    Set {
+
        #[arg(value_name = "PATCH_ID")]
+
        id: Rev,
+

+
        #[arg(long, value_name = "REF", value_parser = parse_refstr)]
+
        remote: Option<RefString>,
+
    },
+

+
    Comment(CommentArgs),
+

+
    Cache {
+
        #[arg(value_name = "PATCH_ID")]
+
        id: Option<Rev>,
+

+
        #[arg(long)]
+
        storage: bool,
+
    },
+
}
+

+
impl Command {
+
    pub(super) fn should_announce(&self) -> bool {
+
        match self {
+
            Self::Update { .. }
+
            | Self::Archive { .. }
+
            | Self::Ready { .. }
+
            | Self::Delete { .. }
+
            | Self::Comment { .. }
+
            | Self::Review { .. }
+
            | Self::Resolve { .. }
+
            | Self::Assign { .. }
+
            | Self::Label { .. }
+
            | Self::Edit { .. }
+
            | Self::Redact { .. }
+
            | Self::React { .. }
+
            | Self::Set { .. } => true,
+
            Self::Show { .. }
+
            | Self::Diff { .. }
+
            | Self::Checkout { .. }
+
            | Self::List { .. }
+
            | Self::Cache { .. } => false,
+
        }
+
    }
+
}
+

+
#[derive(Parser, Debug)]
+
pub(super) struct CommentArgs {
+
    #[arg(value_name = "REVISION_ID")]
+
    revision: Rev,
+

+
    #[arg(long, short)]
+
    message: Option<Message>,
+

+
    #[arg(
+
        long,
+
        value_name = "COMMENT_ID",
+
        conflicts_with = "react",
+
        conflicts_with = "redact"
+
    )]
+
    edit: Option<Rev>,
+

+
    #[arg(
+
        long,
+
        value_name = "COMMENT_ID",
+
        conflicts_with = "edit",
+
        conflicts_with = "redact",
+
        requires = "emoji"
+
    )]
+
    react: Option<Rev>,
+

+
    #[arg(
+
        long,
+
        value_name = "COMMENT_ID",
+
        conflicts_with = "react",
+
        conflicts_with = "edit"
+
    )]
+
    redact: Option<Rev>,
+

+
    #[arg(long)]
+
    emoji: Option<radicle::cob::Reaction>,
+

+
    #[arg(long, value_name = "COMMENT_ID")]
+
    reply_to: Option<Rev>,
+

+
    #[arg(long)]
+
    undo: bool,
+
}
+

+
#[derive(Debug)]
+
pub(super) enum CommentAction {
+
    Comment {
+
        revision: Rev,
+
        message: Message,
+
        reply_to: Option<Rev>,
+
    },
+
    Edit {
+
        revision: Rev,
+
        comment: Rev,
+
        message: Message,
+
    },
+
    Redact {
+
        revision: Rev,
+
        comment: Rev,
+
    },
+
    React {
+
        revision: Rev,
+
        comment: Rev,
+
        emoji: radicle::cob::Reaction,
+
        undo: bool,
+
    },
+
}
+

+
impl From<CommentArgs> for CommentAction {
+
    fn from(
+
        CommentArgs {
+
            revision,
+
            message,
+
            edit,
+
            react,
+
            redact,
+
            reply_to,
+
            emoji,
+
            undo,
+
        }: CommentArgs,
+
    ) -> Self {
+
        match (edit, react, redact) {
+
            (Some(edit), None, None) => CommentAction::Edit {
+
                revision,
+
                comment: edit,
+
                message: message.unwrap_or_default(),
+
            },
+
            (None, Some(react), None) => CommentAction::React {
+
                revision,
+
                comment: react,
+
                emoji: emoji.unwrap(),
+
                undo,
+
            },
+
            (None, None, Some(redact)) => CommentAction::Redact {
+
                revision,
+
                comment: redact,
+
            },
+
            (None, None, None) => Self::Comment {
+
                revision,
+
                message: message.unwrap_or_default(),
+
                reply_to,
+
            },
+
            _ => unreachable!("the argument cannot be used together'"),
+
        }
+
    }
+
}
+

+
#[derive(Parser, Debug, Default)]
+
pub(super) struct EmptyArgs {
+
    #[arg(long, hide = true, value_name = "DID", num_args = 1.., action = clap::ArgAction::Append)]
+
    pub(super) authors: Vec<Did>,
+

+
    #[arg(long, hide = true)]
+
    pub(super) authored: bool,
+

+
    #[clap(flatten)]
+
    pub(super) state: EmptyStateArgs,
+
}
+

+
#[derive(Parser, Debug, Default)]
+
#[group(multiple = false)]
+
pub(super) struct EmptyStateArgs {
+
    #[arg(long, hide = true)]
+
    pub(super) all: bool,
+

+
    #[arg(long, hide = true)]
+
    pub(super) draft: bool,
+

+
    #[arg(long, hide = true)]
+
    pub(super) open: bool,
+

+
    #[arg(long, hide = true)]
+
    pub(super) merged: bool,
+

+
    #[arg(long, hide = true)]
+
    pub(super) archived: bool,
+
}
+

+
#[derive(Parser, Debug, Default)]
+
pub(super) struct ListArgs {
+
    #[arg(long, hide = true, value_name = "DID", num_args = 1.., action = clap::ArgAction::Append)]
+
    pub(super) authors: Vec<Did>,
+

+
    #[arg(long)]
+
    pub(super) authored: bool,
+

+
    #[clap(flatten)]
+
    pub(super) state: ListStateArgs,
+
}
+

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

+
#[derive(Parser, Debug, Default)]
+
#[group(multiple = false)]
+
pub(crate) struct ListStateArgs {
+
    #[arg(long)]
+
    pub(crate) all: bool,
+

+
    #[arg(long)]
+
    pub(crate) draft: bool,
+

+
    #[arg(long)]
+
    pub(crate) open: bool,
+

+
    #[arg(long)]
+
    pub(crate) merged: bool,
+

+
    #[arg(long)]
+
    pub(crate) archived: bool,
+
}
+

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

+
impl From<&ListStateArgs> for Option<&Status> {
+
    fn from(args: &ListStateArgs) -> Self {
+
        match (args.all, args.draft, args.open, args.merged, args.archived) {
+
            (true, false, false, false, false) => None,
+
            (false, true, false, false, false) => Some(&Status::Draft),
+
            (false, false, true, false, false) | (false, false, false, false, false) => {
+
                Some(&Status::Open)
+
            }
+
            (false, false, false, true, false) => Some(&Status::Merged),
+
            (false, false, false, false, true) => Some(&Status::Archived),
+
            _ => unreachable!(),
+
        }
+
    }
+
}
+

+
#[derive(Debug, Parser)]
+
pub struct ReviewArgs {
+
    #[arg(long, short)]
+
    pub patch: bool,
+

+
    #[arg(long, short = 'U')]
+
    pub unified: Option<usize>,
+

+
    #[arg(long, value_name = "INDEX")]
+
    pub hunk: Option<usize>,
+

+
    #[arg(long)]
+
    pub by_hunk: bool,
+

+
    #[clap(flatten)]
+
    message_args: MessageArgs,
+

+
    #[clap(flatten)]
+
    action_args: ReviewActionsArgs,
+
}
+

+
#[derive(Debug, Parser)]
+
#[group(multiple = false)]
+
pub struct ReviewActionsArgs {
+
    #[arg(long)]
+
    pub accept: bool,
+

+
    #[arg(long)]
+
    pub reject: bool,
+

+
    #[arg(long, short)]
+
    pub delete: bool,
+
}
+

+
impl From<ReviewArgs> for review::Options {
+
    fn from(args: ReviewArgs) -> Self {
+
        Self {
+
            message: args.message_args.message,
+
            op: review::Operation::from(args.action_args),
+
        }
+
    }
+
}
+

+
impl From<ReviewActionsArgs> for review::Operation {
+
    fn from(args: ReviewActionsArgs) -> Self {
+
        match (args.accept, args.reject, args.delete) {
+
            (false, false, false) => review::Operation::default(),
+
            (true, false, false) => review::Operation::Review(review::ReviewOptions {
+
                verdict: Some(Verdict::Accept),
+
                ..Default::default()
+
            }),
+
            (false, true, false) => review::Operation::Review(review::ReviewOptions {
+
                verdict: Some(Verdict::Reject),
+
                ..Default::default()
+
            }),
+
            (false, false, true) => review::Operation::Delete,
+
            _ => unreachable!("`--accept`, `--reject` and `--delete` can not be used together"),
+
        }
+
    }
+
}
+

+
#[derive(Debug, clap::Args)]
+
#[group(required = false, multiple = false)]
+
pub(super) struct MessageArgs {
+
    #[arg(long, short)]
+
    pub(super) message: Message,
+

+
    #[arg(long)]
+
    pub(super) no_message: bool,
+
}
+

+
#[derive(Debug, clap::Args)]
+
#[group(required = false, multiple = false)]
+
pub(super) struct LabelArgs {
+
    #[arg(long, value_name = "LABEL", num_args = 1.., action = clap::ArgAction::Append)]
+
    pub(super) add: Option<Label>,
+

+
    #[arg(long, value_name = "LABEL", num_args = 1.., action = clap::ArgAction::Append)]
+
    pub(super) delete: Option<Label>,
+
}
+

+
#[derive(Debug, clap::Args)]
+
pub(super) struct CheckoutArgs {
+
    #[arg(long, value_parser = parse_refstr)]
+
    pub(super) name: Option<RefString>,
+

+
    #[arg(long, value_parser = parse_refstr)]
+
    pub(super) remote: Option<RefString>,
+

+
    #[arg(long, short)]
+
    pub(super) force: bool,
+
}
+

+
impl From<CheckoutArgs> for checkout::Options {
+
    fn from(value: CheckoutArgs) -> Self {
+
        Self {
+
            name: value.name,
+
            remote: value.remote,
+
            force: value.force,
+
        }
+
    }
+
}
+

+
fn parse_refstr(refstr: &str) -> Result<RefString, git::fmt::Error> {
+
    RefString::try_from(refstr)
+
}
modified crates/radicle-cli/src/commands/patch/assign.rs
@@ -9,8 +9,8 @@ use crate::terminal as term;

pub fn run(
    patch_id: &PatchId,
-
    add: BTreeSet<Did>,
-
    delete: BTreeSet<Did>,
+
    add: Vec<Did>,
+
    delete: Vec<Did>,
    profile: &Profile,
    repository: &Repository,
) -> anyhow::Result<()> {
modified crates/radicle-cli/src/commands/patch/label.rs
@@ -6,8 +6,8 @@ use crate::terminal as term;

pub fn run(
    patch_id: &PatchId,
-
    add: BTreeSet<Label>,
-
    delete: BTreeSet<Label>,
+
    add: Vec<Label>,
+
    delete: Vec<Label>,
    profile: &Profile,
    repository: &Repository,
) -> anyhow::Result<()> {
modified crates/radicle-cli/src/commands/patch/review.rs
@@ -21,19 +21,16 @@ Markdown supported.
"#;

#[derive(Debug, PartialEq, Eq)]
-
pub enum Operation {
-
    Delete,
-
    Review {
-
        by_hunk: bool,
-
        unified: usize,
-
        hunk: Option<usize>,
-
        verdict: Option<Verdict>,
-
    },
+
pub struct ReviewOptions {
+
    pub by_hunk: bool,
+
    pub unified: usize,
+
    pub hunk: Option<usize>,
+
    pub verdict: Option<Verdict>,
}

-
impl Default for Operation {
+
impl Default for ReviewOptions {
    fn default() -> Self {
-
        Self::Review {
+
        Self {
            by_hunk: false,
            unified: 3,
            hunk: None,
@@ -42,6 +39,18 @@ impl Default for Operation {
    }
}

+
#[derive(Debug, PartialEq, Eq)]
+
pub enum Operation {
+
    Delete,
+
    Review(ReviewOptions),
+
}
+

+
impl Default for Operation {
+
    fn default() -> Self {
+
        Operation::Review(ReviewOptions::default())
+
    }
+
}
+

#[derive(Debug, Default)]
pub struct Options {
    pub message: Message,
@@ -77,24 +86,19 @@ pub fn run(

    let patch_id_pretty = term::format::tertiary(term::format::cob(&patch_id));
    match options.op {
-
        Operation::Review {
-
            verdict,
-
            by_hunk,
-
            unified,
-
            hunk,
-
        } if by_hunk => {
+
        Operation::Review(review_opts) if review_opts.by_hunk => {
            crate::warning::obsolete("rad patch review --patch");
            let mut opts = git::raw::DiffOptions::new();
            opts.patience(true)
                .minimal(true)
-
                .context_lines(unified as u32);
+
                .context_lines(review_opts.unified as u32);

            builder::ReviewBuilder::new(patch_id, repository)
-
                .hunk(hunk)
-
                .verdict(verdict)
+
                .hunk(review_opts.hunk)
+
                .verdict(review_opts.verdict)
                .run(revision, &mut opts, &signer)?;
        }
-
        Operation::Review { verdict, .. } => {
+
        Operation::Review(review_opts) => {
            let message = options.message.get(REVIEW_HELP_MSG)?;
            let message = message.replace(REVIEW_HELP_MSG.trim(), "");
            let message = if message.is_empty() {
@@ -102,9 +106,9 @@ pub fn run(
            } else {
                Some(message)
            };
-
            patch.review(revision_id, verdict, message, vec![], &signer)?;
+
            patch.review(revision_id, review_opts.verdict, message, vec![], &signer)?;

-
            match verdict {
+
            match review_opts.verdict {
                Some(Verdict::Accept) => {
                    term::success!(
                        "Patch {} {}",
modified crates/radicle-cli/src/commands/patch/show.rs
@@ -4,7 +4,7 @@ use radicle::cob::patch;
use radicle::git;
use radicle::storage::git::Repository;

-
use crate::terminal as term;
+
use crate::terminal::{self as term, Error};

use super::*;

modified crates/radicle-cli/src/main.rs
@@ -67,6 +67,7 @@ enum Commands {
    Init(init::Args),
    Issue(issue::Args),
    Ls(ls::Args),
+
    Patch(patch::Args),
    Path(path::Args),
    Publish(publish::Args),
    Seed(seed::Args),
@@ -283,7 +284,9 @@ pub(crate) fn run_other(exe: &str, args: &[OsString]) -> Result<(), Option<anyho
            term::run_command_args::<node::Options, _>(node::HELP, node::run, args.to_vec());
        }
        "patch" => {
-
            term::run_command_args::<patch::Options, _>(patch::HELP, patch::run, args.to_vec());
+
            if let Some(Commands::Patch(args)) = CliArgs::parse().command {
+
                term::run_command_fn(patch::run, args);
+
            }
        }
        "path" => {
            if let Some(Commands::Path(args)) = CliArgs::parse().command {