Radish alpha
h
Radicle Heartwood Protocol & Stack
Radicle
Git (anonymous pull)
Log in to clone via SSH
Add patch CLI parsing with clap
Matthias Beyer committed 8 months ago
commit 3e53b8f78a0d1070b665138c065b43d3d9263c85
parent 916668ea770b3375224f6f4c698ef7c1e5b45f37
4 files changed +429 -721
modified crates/radicle-cli/src/commands/hints.rs
@@ -66,6 +66,11 @@ pub(crate) fn issue_ids_completer(current: &std::ffi::OsStr) -> Vec<CompletionCa
    result.into_iter().map(CompletionCandidate::new).collect()
}

+
/// Wrapper for [`issue_ids`] to support clap API.
+
pub(crate) fn patch_ids_completer(current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
+
    todo!()
+
}
+

/// List the `DID`s associated with the current repository, filtered by the `prefix`.
// TODO: we could make this more like a fuzzy search
pub fn dids(prefix: &str) -> Option<Vec<String>> {
modified crates/radicle-cli/src/commands/patch.rs
@@ -33,23 +33,26 @@ mod show;
#[path = "patch/update.rs"]
mod update;

+
#[path = "patch/args.rs"]
+
mod args;
+

+
pub use self::args::Args;
+

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::RefString;
+
use radicle::cob::{patch, Label};
use radicle::patch::cache::Patches as _;
use radicle::storage::git::transport;
use radicle::{prelude::*, Node};

+
use crate::commands::rad_patch::args::{Command, CommentSubcommand};
use crate::git::Rev;
use crate::node;
use crate::terminal as term;
-
use crate::terminal::args::{string, Args, Error, Help};
+
use crate::terminal::args::{Error, Help};
use crate::terminal::patch::Message;

pub const HELP: Help = Help {
@@ -183,677 +186,8 @@ Other options
"#,
};

-
#[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()
@@ -863,37 +197,39 @@ 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();
+
    let announce = !args.no_announce && args.command.is_some_and(|c| c.is_announce());

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

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

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

+
        Some(Command::Diff {
            patch_id,
            revision_id,
-
        } => {
+
        }) => {
            let patch_id = patch_id.resolve(&repository.backend)?;
            let revision_id = revision_id
                .map(|rev| rev.resolve::<radicle::git::Oid>(&repository.backend))
@@ -901,11 +237,12 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                .map(patch::RevisionId::from);
            diff::run(&patch_id, revision_id, &repository, &profile)?;
        }
-
        Operation::Update {
+

+
        Some(Command::Update {
            ref patch_id,
            ref base_id,
-
            ref message,
-
        } => {
+
            message: crate::commands::rad_patch::args::UpdateMessageArg { message, no_message: _ },
+
        }) => {
            let patch_id = patch_id.resolve(&repository.backend)?;
            let base_id = base_id
                .as_ref()
@@ -924,11 +261,13 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                &workdir,
            )?;
        }
-
        Operation::Archive { ref patch_id, undo } => {
+

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

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

            if !ready::run(&patch_id, undo, &profile, &repository)? {
@@ -939,15 +278,17 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                }
            }
        }
-
        Operation::Delete { patch_id } => {
+

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

+
        Some(Command::Checkout {
            patch_id,
            revision_id,
            opts,
-
        } => {
+
        }) => {
            let patch_id = patch_id.resolve::<radicle::git::Oid>(&repository.backend)?;
            let revision_id = revision_id
                .map(|rev| rev.resolve::<radicle::git::Oid>(&repository.backend))
@@ -962,14 +303,15 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                &repository,
                &workdir,
                &profile,
-
                opts,
+
                opts.into(),
            )?;
        }
-
        Operation::Comment {
+

+
        Some(Command::Comment {
            revision_id,
            message,
            reply_to,
-
        } => {
+
        }) => {
            comment::run(
                revision_id,
                message,
@@ -979,11 +321,11 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                &profile,
            )?;
        }
-
        Operation::Review {
+
        Some(Command::Review {
            patch_id,
            revision_id,
            opts,
-
        } => {
+
        }) => {
            let patch_id = patch_id.resolve(&repository.backend)?;
            let revision_id = revision_id
                .map(|rev| rev.resolve::<radicle::git::Oid>(&repository.backend))
@@ -991,12 +333,12 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                .map(patch::RevisionId::from);
            review::run(patch_id, revision_id, opts, &profile, &repository)?;
        }
-
        Operation::Resolve {
+
        Some(Command::Resolve {
            ref patch_id,
            ref review_id,
            ref comment_id,
            undo,
-
        } => {
+
        }) => {
            let patch = patch_id.resolve(&repository.backend)?;
            let review = patch::ReviewId::from(
                review_id.resolve::<radicle::cob::EntryId>(&repository.backend)?,
@@ -1010,11 +352,11 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                term::success!("Resolved comment {comment_id}");
            }
        }
-
        Operation::Edit {
+
        Some(Command::Edit {
            patch_id,
            revision_id,
            message,
-
        } => {
+
        }) => {
            let patch_id = patch_id.resolve(&repository.backend)?;
            let revision_id = revision_id
                .map(|id| id.resolve::<radicle::git::Oid>(&repository.backend))
@@ -1022,24 +364,24 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                .map(patch::RevisionId::from);
            edit::run(&patch_id, revision_id, message, &profile, &repository)?;
        }
-
        Operation::Redact { revision_id } => {
+
        Some(Command::Redact { revision_id }) => {
            redact::run(&revision_id, &profile, &repository)?;
        }
-
        Operation::Assign {
+
        Some(Command::Assign {
            patch_id,
-
            opts: AssignOptions { add, delete },
-
        } => {
+
            opts: self::args::AssignArg { add, delete },
+
        }) => {
            let patch_id = patch_id.resolve(&repository.backend)?;
            assign::run(&patch_id, add, delete, &profile, &repository)?;
        }
-
        Operation::Label {
+
        Some(Command::Label {
            patch_id,
-
            opts: LabelOptions { add, delete },
-
        } => {
+
            opts: self::args::LabelArgs { add, delete },
+
        }) => {
            let patch_id = patch_id.resolve(&repository.backend)?;
            label::run(&patch_id, add, delete, &profile, &repository)?;
        }
-
        Operation::Set { patch_id, remote } => {
+
        Some(Command::Set { patch_id, remote }) => {
            let patches = term::cob::patches(&profile, &repository)?;
            let patch_id = patch_id.resolve(&repository.backend)?;
            let patch = patches
@@ -1056,7 +398,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                true,
            )?;
        }
-
        Operation::Cache { patch_id, storage } => {
+
        Some(Command::Cache { patch_id, storage }) => {
            let mode = if storage {
                cache::CacheMode::Storage
            } else {
@@ -1075,34 +417,34 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
            };
            cache::run(mode, &profile)?;
        }
-
        Operation::CommentEdit {
+
        Some(Command::Comment { subcommand: CommentSubcommand::Edit {
            revision_id,
            comment_id,
            message,
-
        } => {
+
        }}) => {
            let comment = comment_id.resolve(&repository.backend)?;
            comment::edit::run(
                revision_id,
                comment,
                message,
-
                options.quiet,
+
                args.quiet,
                &repository,
                &profile,
            )?;
        }
-
        Operation::CommentRedact {
+
        Some(Command::Comment { subcommand: CommentSubcommand::Redact {
            revision_id,
            comment_id,
-
        } => {
+
        }}) => {
            let comment = comment_id.resolve(&repository.backend)?;
            comment::redact::run(revision_id, comment, &repository, &profile)?;
        }
-
        Operation::CommentReact {
+
        Some(Command::Comment { subcommand: CommentSubcommand::React {
            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)?;
@@ -1110,17 +452,21 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                comment::react::run(revision_id, comment, reaction, true, &repository, &profile)?;
            }
        }
-
        Operation::React {
+
        Some(Command::React {
            revision_id,
            reaction,
            undo,
-
        } => {
+
        }) => {
            if undo {
                react::run(&revision_id, reaction, false, &repository, &profile)?;
            } else {
                react::run(&revision_id, reaction, true, &repository, &profile)?;
            }
        }
+

+
        None => {
+
            unimplemented!(),
+
        }
    }

    if announce {
added crates/radicle-cli/src/commands/patch/args.rs
@@ -0,0 +1,353 @@
+
use clap::{Parser, Subcommand};
+
use clap_complete::ArgValueCompleter;
+
use radicle::prelude::Did;
+

+
use crate::git::Rev;
+

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

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

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

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

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

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

+
#[derive(Debug, clap::Args)]
+
#[group(required = false, multiple = false)]
+
pub struct ListOptions {
+
    #[arg(long, group = "cmd-list")]
+
    pub all: bool,
+

+
    #[arg(long, group = "cmd-list")]
+
    pub merged: bool,
+

+
    #[arg(long, group = "cmd-list")]
+
    pub open: bool,
+

+
    #[arg(long, group = "cmd-list")]
+
    pub archived: bool,
+

+
    #[arg(long, group = "cmd-list")]
+
    pub draft: bool,
+

+
    #[arg(long, group = "cmd-list")]
+
    pub authored: bool,
+
}
+

+
#[derive(Debug, clap::Args)]
+
#[group(required = false, multiple = false)]
+
pub struct ResolveOptions {
+
    #[arg(long, group = "cmd-resolve")]
+
    pub review: Option<String>,
+

+
    #[arg(long, group = "cmd-resolve")]
+
    pub comment: Option<String>,
+

+
    #[arg(long, group = "cmd-resolve")]
+
    pub unresolve: bool,
+
}
+

+
#[derive(Debug, Parser)]
+
pub struct ReviewOptions {
+
    #[clap(long, group = "cmd-review")]
+
    pub accept: bool,
+

+
    #[clap(long, group = "cmd-review")]
+
    pub reject: bool,
+

+
    #[clap(long, short = 'm', group = "cmd-review")]
+
    pub message: String,
+

+
    #[clap(long, short = 'd', group = "cmd-review")]
+
    pub delete: bool,
+
}
+

+
#[derive(Debug, clap::Args)]
+
#[group(required = false, multiple = false)]
+
pub struct UpdateMessageArg {
+
    #[clap(long = "message", short = 'm', group = "update-message-arg")]
+
    pub message: crate::terminal::patch::Message,
+

+
    #[clap(long = "no-message")]
+
    pub no_message: bool,
+
}
+

+
#[derive(Debug, clap::Args)]
+
#[group(required = false, multiple = false)]
+
pub struct LabelArg {
+
    #[clap(long, group = "label-arg")]
+
    pub add: Option<String>,
+

+
    #[clap(long, group = "label-arg")]
+
    pub delete: Option<String>,
+
}
+

+

+
#[derive(Debug, clap::Args)]
+
#[group(required = false, multiple = false)]
+
pub struct AssignArg {
+
    #[clap(long, group = "assign-arg")]
+
    pub add: Option<String>,
+

+
    #[clap(long, group = "assign-arg")]
+
    pub delete: Option<String>,
+
}
+

+
#[derive(Debug, clap::Args)]
+
pub struct CheckoutOptions {
+
    #[clap(long)]
+
    pub name: Option<git_ref_format::RefString>,
+

+
    #[clap(long)]
+
    pub remote: Option<git_ref_format::RefString>,
+

+
    #[clap(long)]
+
    pub force: bool,
+
}
+

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

+
/// Commands to create, view, and edit Radicle issues
+
#[derive(Subcommand, Debug)]
+
pub enum Command {
+
    List {
+
        #[clap(flatten)]
+
        options: ListOptions,
+

+
        #[clap(long)]
+
        authors: Vec<Did>,
+

+
        #[clap(long)]
+
        filter: Option<radicle::patch::Status>,
+
    },
+

+
    Show {
+
        #[arg(value_name = "PATCH_ID", add = ArgValueCompleter::new(crate::commands::hints::patch_ids_completer))]
+
        patch_id: Rev,
+

+
        // Show the actual patch diff
+
        patch: bool,
+

+
        /// Show additional information about the patch
+
        verbose: bool,
+
    },
+

+
    Diff {
+
        #[arg(value_name = "PATCH_ID", add = ArgValueCompleter::new(crate::commands::hints::patch_ids_completer))]
+
        patch_id: Rev,
+

+
        /// The revision of diff (default: latest)
+
        #[clap(long = "revision", short = 'r')]
+
        revision_id: Option<Rev>,
+
    },
+

+
    Archive {
+
        #[arg(value_name = "PATCH_ID", add = ArgValueCompleter::new(crate::commands::hints::patch_ids_completer))]
+
        patch_id: Rev,
+

+
        undo: bool,
+
    },
+

+
    Update {
+
        #[arg(value_name = "PATCH_ID", add = ArgValueCompleter::new(crate::commands::hints::patch_ids_completer))]
+
        patch_id: Rev,
+

+
        #[clap(long = "base", short = 'n')]
+
        base_id: Option<Rev>,
+

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

+
    Checkout {
+
        #[arg(value_name = "PATCH_ID", add = ArgValueCompleter::new(crate::commands::hints::patch_ids_completer))]
+
        patch_id: Rev,
+

+
        #[clap(long)]
+
        revision_id: Option<Rev>,
+

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

+
    Review {
+
        #[arg(value_name = "PATCH_ID", add = ArgValueCompleter::new(crate::commands::hints::patch_ids_completer))]
+
        patch_id: Rev,
+

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

+
    Resolve {
+
        #[arg(value_name = "PATCH_ID", add = ArgValueCompleter::new(crate::commands::hints::patch_ids_completer))]
+
        patch_id: Rev,
+

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

+
    Delete {
+
        #[arg(value_name = "PATCH_ID", add = ArgValueCompleter::new(crate::commands::hints::patch_ids_completer))]
+
        patch_id: Rev,
+
    },
+

+
    Redact {
+
        #[arg(value_name = "PATCH_ID", add = ArgValueCompleter::new(crate::commands::hints::patch_ids_completer))]
+
        patch_id: Rev,
+
    },
+

+
    React {
+
        #[arg(value_name = "PATCH_ID", add = ArgValueCompleter::new(crate::commands::hints::patch_ids_completer))]
+
        patch_id: Rev,
+

+
        #[clap(long)]
+
        react: Option<radicle::cob::Reaction>,
+
    },
+

+
    Assign {
+
        #[arg(value_name = "PATCH_ID", add = ArgValueCompleter::new(crate::commands::hints::patch_ids_completer))]
+
        patch_id: Rev,
+

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

+
    Label {
+
        #[arg(value_name = "PATCH_ID", add = ArgValueCompleter::new(crate::commands::hints::patch_ids_completer))]
+
        patch_id: Rev,
+

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

+
    Ready {
+
        #[arg(value_name = "PATCH_ID", add = ArgValueCompleter::new(crate::commands::hints::patch_ids_completer))]
+
        patch_id: Rev,
+

+
        undo: bool,
+
    },
+

+
    Edit {
+
        #[arg(value_name = "PATCH_ID", add = ArgValueCompleter::new(crate::commands::hints::patch_ids_completer))]
+
        patch_id: Rev,
+
    },
+

+
    Set {
+
        #[arg(value_name = "PATCH_ID", add = ArgValueCompleter::new(crate::commands::hints::patch_ids_completer))]
+
        patch_id: Rev,
+
    },
+

+
    Comment {
+
        #[command(subcommand)]
+
        subcommand: CommentSubcommand,
+
    },
+

+
    Cache {
+
        #[arg(value_name = "PATCH_ID", add = ArgValueCompleter::new(crate::commands::hints::patch_ids_completer))]
+
        patch_id: Option<Rev>,
+

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

+
#[derive(Subcommand, Debug)]
+
pub enum CommentSubcommand {
+
    Edit {
+
        #[arg(value_name = "REVISION", add = ArgValueCompleter::new(crate::commands::hints::patch_ids_completer))]
+
        revision_id: Rev,
+
        #[arg(value_name = "COMMENT", add = ArgValueCompleter::new(crate::commands::hints::patch_ids_completer))]
+
        comment_id: Rev,
+

+
        #[arg(long, short)]
+
        message: crate::terminal::patch::Message,
+
    },
+
    Redact {
+
        #[arg(value_name = "REVISION", add = ArgValueCompleter::new(crate::commands::hints::patch_ids_completer))]
+
        revision_id: Rev,
+
        #[arg(value_name = "COMMENT", add = ArgValueCompleter::new(crate::commands::hints::patch_ids_completer))]
+
        comment_id: Rev,
+
    },
+
    React {
+
        #[arg(value_name = "REVISION", add = ArgValueCompleter::new(crate::commands::hints::patch_ids_completer))]
+
        revision_id: Rev,
+
        #[arg(value_name = "COMMENT", add = ArgValueCompleter::new(crate::commands::hints::patch_ids_completer))]
+
        comment_id: Rev,
+

+
        #[arg(long)]
+
        reaction: radicle::cob::Reaction,
+

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

+
impl Command {
+
    pub(super) fn is_announce(&self) -> bool {
+
        match self {
+
            Self::Update { .. }
+
            | Self::Archive { .. }
+
            | Self::Ready { .. }
+
            | Self::Delete { .. }
+
            | Self::Comment {
+
                subcommand: CommentSubcommand::Edit { .. },
+
                ..
+
            }
+
            | Self::Comment {
+
                subcommand: CommentSubcommand::Redact { .. },
+
                ..
+
            }
+
            | Self::Comment {
+
                subcommand: CommentSubcommand::React { .. },
+
                ..
+
            }
+
            | 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,
+
        }
+
    }
+
}
modified crates/radicle-cli/src/main.rs
@@ -10,6 +10,7 @@ use clap_complete::Shell;

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

@@ -59,6 +60,9 @@ enum Commands {
    ///
    /// With issues you can organize your project and use it to discuss bugs and improvements.
    Issue(rad_issue::Args),
+

+
    /// Commands to create, view, and edit Radicle patches
+
    Patch(rad_patch::Args),
}

#[derive(Debug)]