Skip to main content

radicle_cli/commands/patch/
args.rs

1use clap::{Parser, Subcommand};
2
3use radicle::cob::Label;
4use radicle::git;
5use radicle::git::fmt::RefString;
6use radicle::patch::Status;
7use radicle::patch::Verdict;
8use radicle::prelude::Did;
9use radicle::prelude::RepoId;
10
11use crate::commands::patch::checkout;
12use crate::commands::patch::review;
13
14use crate::git::Rev;
15use crate::terminal::patch::Message;
16
17const ABOUT: &str = "Manage patches";
18
19#[derive(Debug, Parser)]
20#[command(about = ABOUT, disable_version_flag = true)]
21pub struct Args {
22    #[command(subcommand)]
23    pub(super) command: Option<Command>,
24
25    /// Quiet output
26    #[arg(short, long, global = true)]
27    pub(super) quiet: bool,
28
29    /// Announce changes made to the network
30    #[arg(long, global = true, conflicts_with = "no_announce")]
31    announce: bool,
32
33    /// Do not announce changes made to the network
34    #[arg(long, global = true, conflicts_with = "announce")]
35    no_announce: bool,
36
37    /// Operate on the given repository [default: cwd]
38    #[arg(long, global = true, value_name = "RID")]
39    pub(super) repo: Option<RepoId>,
40
41    /// Verbose output
42    #[arg(long, short, global = true)]
43    pub(super) verbose: bool,
44
45    /// Arguments for the empty subcommand.
46    /// Will fall back to [`Command::List`].
47    #[clap(flatten)]
48    pub(super) empty: EmptyArgs,
49}
50
51impl Args {
52    pub(super) fn should_announce(&self) -> bool {
53        self.announce || !self.no_announce
54    }
55}
56
57/// Commands to create, view, and edit Radicle patches
58#[derive(Subcommand, Debug)]
59pub(super) enum Command {
60    /// List the patches of a repository
61    #[command(alias = "l")]
62    List(ListArgs),
63
64    /// Show a specific patch
65    #[command(alias = "s")]
66    Show {
67        /// ID of the patch
68        #[arg(value_name = "PATCH_ID")]
69        id: Rev,
70
71        /// Show the diff of the changes in the patch
72        #[arg(long, short)]
73        patch: bool,
74
75        /// Verbose output
76        #[arg(long, short)]
77        verbose: bool,
78    },
79
80    /// Show the diff of a specific patch
81    ///
82    /// The `git diff` of the revision's base and head will be shown
83    Diff {
84        /// ID of the patch
85        #[arg(value_name = "PATCH_ID")]
86        id: Rev,
87
88        /// The revision to diff
89        ///
90        /// If not specified, the latest revision of the original author
91        /// will be used
92        #[arg(long, short)]
93        revision: Option<Rev>,
94    },
95
96    /// Mark a patch as archived
97    #[command(alias = "a")]
98    Archive {
99        /// ID of the patch
100        #[arg(value_name = "PATCH_ID")]
101        id: Rev,
102
103        /// Unarchive a patch
104        ///
105        /// The patch will be marked as open
106        #[arg(long)]
107        undo: bool,
108    },
109
110    /// Update the metadata of a patch
111    #[command(alias = "u")]
112    Update {
113        /// ID of the patch
114        #[arg(value_name = "PATCH_ID")]
115        id: Rev,
116
117        /// Provide a Git revision as the base commit
118        #[arg(long, short, value_name = "REVSPEC")]
119        base: Option<Rev>,
120
121        /// Change the message of the original revision of the patch
122        #[clap(flatten)]
123        message: MessageArgs,
124    },
125
126    /// Checkout a Git branch pointing to the head of a patch revision
127    ///
128    /// If no revision is specified, the latest revision of the original author
129    /// is chosen
130    #[command(alias = "c")]
131    Checkout {
132        /// ID of the patch
133        #[arg(value_name = "PATCH_ID")]
134        id: Rev,
135
136        /// Checkout the given revision of the patch
137        #[arg(long)]
138        revision: Option<Rev>,
139
140        #[clap(flatten)]
141        opts: CheckoutArgs,
142    },
143
144    /// Create a review of a patch revision
145    Review {
146        /// ID of the patch
147        #[arg(value_name = "PATCH_ID")]
148        id: Rev,
149
150        /// The particular revision to review
151        ///
152        /// If none is specified, the initial revision will be reviewed
153        #[arg(long, short)]
154        revision: Option<Rev>,
155
156        #[clap(flatten)]
157        options: ReviewArgs,
158    },
159
160    /// Mark a comment of a review as resolved or unresolved
161    Resolve {
162        /// ID of the patch
163        #[arg(value_name = "PATCH_ID")]
164        id: Rev,
165
166        /// The review id which the comment is under
167        #[arg(long, value_name = "REVIEW_ID")]
168        review: Rev,
169
170        /// The comment to (un)resolve
171        #[arg(long, value_name = "COMMENT_ID")]
172        comment: Rev,
173
174        /// Unresolve the comment
175        #[arg(long)]
176        unresolve: bool,
177    },
178
179    /// Delete a patch
180    ///
181    /// This will delete any patch data associated with this user. Note that
182    /// other user's data will remain, meaning the patch will remain until all
183    /// other data is also deleted.
184    #[command(alias = "d")]
185    Delete {
186        /// ID of the patch
187        #[arg(value_name = "PATCH_ID")]
188        id: Rev,
189    },
190
191    /// Redact a patch revision
192    #[command(alias = "r")]
193    Redact {
194        /// ID of the patch revision
195        #[arg(value_name = "REVISION_ID")]
196        id: Rev,
197    },
198
199    /// React to a patch or patch revision
200    React {
201        /// ID of the patch or patch revision
202        #[arg(value_name = "PATCH_ID|REVISION_ID")]
203        id: Rev,
204
205        /// The reaction being used
206        #[arg(long, value_name = "CHAR")]
207        emoji: radicle::cob::Reaction,
208
209        /// Remove the reaction
210        #[arg(long)]
211        undo: bool,
212    },
213
214    /// Add or remove assignees to/from a patch
215    Assign {
216        /// ID of the patch
217        #[arg(value_name = "PATCH_ID")]
218        id: Rev,
219
220        #[clap(flatten)]
221        args: AssignArgs,
222    },
223
224    /// Add or remove labels to/from a patch
225    Label {
226        /// ID of the patch
227        #[arg(value_name = "PATCH_ID")]
228        id: Rev,
229
230        #[clap(flatten)]
231        args: LabelArgs,
232    },
233
234    /// If the patch is marked as a draft, then mark it as open
235    #[command(alias = "y")]
236    Ready {
237        /// ID of the patch
238        #[arg(value_name = "PATCH_ID")]
239        id: Rev,
240
241        /// Convert a patch back to a draft
242        #[arg(long)]
243        undo: bool,
244    },
245
246    #[command(alias = "e")]
247    Edit {
248        /// ID of the patch
249        #[arg(value_name = "PATCH_ID")]
250        id: Rev,
251
252        /// ID of the patch revision
253        #[arg(long, value_name = "REVISION_ID")]
254        revision: Option<Rev>,
255
256        #[clap(flatten)]
257        message: MessageArgs,
258    },
259
260    /// Set an upstream branch for a patch
261    Set {
262        /// ID of the patch
263        #[arg(value_name = "PATCH_ID")]
264        id: Rev,
265
266        /// Provide the git remote to use as the upstream
267        #[arg(long, value_name = "REF", value_parser = parse_refstr)]
268        remote: Option<RefString>,
269    },
270
271    /// Comment on, reply to, edit, or react to a comment
272    Comment(CommentArgs),
273
274    /// Re-cache the patches
275    Cache {
276        /// ID of the patch
277        #[arg(value_name = "PATCH_ID")]
278        id: Option<Rev>,
279
280        /// Re-cache all patches in storage, as opposed to the current repository
281        #[arg(long)]
282        storage: bool,
283    },
284}
285
286impl Command {
287    pub(super) fn should_announce(&self) -> bool {
288        match self {
289            Self::Update { .. }
290            | Self::Archive { .. }
291            | Self::Ready { .. }
292            | Self::Delete { .. }
293            | Self::Comment { .. }
294            | Self::Review { .. }
295            | Self::Resolve { .. }
296            | Self::Assign { .. }
297            | Self::Label { .. }
298            | Self::Edit { .. }
299            | Self::Redact { .. }
300            | Self::React { .. }
301            | Self::Set { .. } => true,
302            Self::Show { .. }
303            | Self::Diff { .. }
304            | Self::Checkout { .. }
305            | Self::List { .. }
306            | Self::Cache { .. } => false,
307        }
308    }
309}
310
311#[derive(Parser, Debug)]
312pub(super) struct CommentArgs {
313    /// ID of the revision to comment on
314    #[arg(value_name = "REVISION_ID")]
315    revision: Rev,
316
317    #[clap(flatten)]
318    message: MessageArgs,
319
320    /// The comment to edit
321    ///
322    /// Use `--message` to edit with the provided message
323    #[arg(
324        long,
325        value_name = "COMMENT_ID",
326        conflicts_with = "react",
327        conflicts_with = "redact"
328    )]
329    edit: Option<Rev>,
330
331    /// The comment to react to
332    ///
333    /// Use `--emoji` for the character to react with
334    ///
335    /// Use `--undo` with `--emoji` to remove the reaction
336    #[arg(
337        long,
338        value_name = "COMMENT_ID",
339        conflicts_with = "edit",
340        conflicts_with = "redact",
341        requires = "emoji",
342        group = "reaction"
343    )]
344    react: Option<Rev>,
345
346    /// The comment to redact
347    #[arg(
348        long,
349        value_name = "COMMENT_ID",
350        conflicts_with = "react",
351        conflicts_with = "edit"
352    )]
353    redact: Option<Rev>,
354
355    /// The emoji to react with
356    ///
357    /// Requires using `--react <COMMENT_ID>`
358    #[arg(long, requires = "reaction")]
359    emoji: Option<radicle::cob::Reaction>,
360
361    /// The comment to reply to
362    #[arg(long, value_name = "COMMENT_ID")]
363    reply_to: Option<Rev>,
364
365    /// Remove the reaction
366    ///
367    /// Requires using `--react <COMMENT_ID> --emoji <EMOJI>`
368    #[arg(long, requires = "reaction")]
369    undo: bool,
370}
371
372#[derive(Debug)]
373pub(super) enum CommentAction {
374    Comment {
375        revision: Rev,
376        message: Message,
377        reply_to: Option<Rev>,
378    },
379    Edit {
380        revision: Rev,
381        comment: Rev,
382        message: Message,
383    },
384    Redact {
385        revision: Rev,
386        comment: Rev,
387    },
388    React {
389        revision: Rev,
390        comment: Rev,
391        emoji: radicle::cob::Reaction,
392        undo: bool,
393    },
394}
395
396impl From<CommentArgs> for CommentAction {
397    fn from(
398        CommentArgs {
399            revision,
400            message,
401            edit,
402            react,
403            redact,
404            reply_to,
405            emoji,
406            undo,
407        }: CommentArgs,
408    ) -> Self {
409        match (edit, react, redact) {
410            (Some(edit), None, None) => CommentAction::Edit {
411                revision,
412                comment: edit,
413                message: Message::from(message),
414            },
415            (None, Some(react), None) => CommentAction::React {
416                revision,
417                comment: react,
418                emoji: emoji.expect("emoji must be Some when react is Some"),
419                undo,
420            },
421            (None, None, Some(redact)) => CommentAction::Redact {
422                revision,
423                comment: redact,
424            },
425            (None, None, None) => Self::Comment {
426                revision,
427                message: Message::from(message),
428                reply_to,
429            },
430            _ => unreachable!("`--edit`, `--react`, and `--redact` cannot be used together"),
431        }
432    }
433}
434
435#[derive(Parser, Debug, Default)]
436pub(super) struct EmptyArgs {
437    #[arg(long, hide = true, value_name = "DID", num_args = 1.., action = clap::ArgAction::Append)]
438    authors: Vec<Did>,
439
440    #[arg(long, hide = true)]
441    authored: bool,
442
443    #[clap(flatten)]
444    state: EmptyStateArgs,
445}
446
447#[derive(Parser, Debug, Default)]
448#[group(multiple = false)]
449pub(super) struct EmptyStateArgs {
450    #[arg(long, hide = true)]
451    all: bool,
452
453    #[arg(long, hide = true)]
454    draft: bool,
455
456    #[arg(long, hide = true)]
457    open: bool,
458
459    #[arg(long, hide = true)]
460    merged: bool,
461
462    #[arg(long, hide = true)]
463    archived: bool,
464}
465
466#[derive(Parser, Debug, Default)]
467pub(super) struct ListArgs {
468    /// Show only patched where the given user is an author (may be specified
469    /// multiple times)
470    #[arg(
471        long = "author",
472        value_name = "DID",
473        num_args = 1..,
474        action = clap::ArgAction::Append,
475    )]
476    pub(super) authors: Vec<Did>,
477
478    /// Show only patches that you have authored
479    #[arg(long)]
480    pub(super) authored: bool,
481
482    #[clap(flatten)]
483    pub(super) state: ListStateArgs,
484}
485
486impl From<EmptyArgs> for ListArgs {
487    fn from(args: EmptyArgs) -> Self {
488        Self {
489            authors: args.authors,
490            authored: args.authored,
491            state: ListStateArgs::from(args.state),
492        }
493    }
494}
495
496#[derive(Parser, Debug, Default)]
497#[group(multiple = false)]
498pub(crate) struct ListStateArgs {
499    /// Show all patches, including draft, merged, and archived patches
500    #[arg(long)]
501    pub(crate) all: bool,
502
503    /// Show only draft patches
504    #[arg(long)]
505    pub(crate) draft: bool,
506
507    /// Show only open patches (default)
508    #[arg(long)]
509    pub(crate) open: bool,
510
511    /// Show only merged patches
512    #[arg(long)]
513    pub(crate) merged: bool,
514
515    /// Show only archived patches
516    #[arg(long)]
517    pub(crate) archived: bool,
518}
519
520impl From<EmptyStateArgs> for ListStateArgs {
521    fn from(args: EmptyStateArgs) -> Self {
522        Self {
523            all: args.all,
524            draft: args.draft,
525            open: args.open,
526            merged: args.merged,
527            archived: args.archived,
528        }
529    }
530}
531
532impl From<&ListStateArgs> for Option<&Status> {
533    fn from(args: &ListStateArgs) -> Self {
534        match (args.all, args.draft, args.open, args.merged, args.archived) {
535            (true, false, false, false, false) => None,
536            (false, true, false, false, false) => Some(&Status::Draft),
537            (false, false, true, false, false) | (false, false, false, false, false) => {
538                Some(&Status::Open)
539            }
540            (false, false, false, true, false) => Some(&Status::Merged),
541            (false, false, false, false, true) => Some(&Status::Archived),
542            _ => unreachable!(),
543        }
544    }
545}
546
547#[derive(Debug, Parser)]
548pub(super) struct ReviewArgs {
549    /// Review by patch hunks
550    ///
551    /// This operation is obsolete
552    #[arg(long, short, group = "by-hunk", conflicts_with = "delete")]
553    patch: bool,
554
555    /// Generate diffs with <N> lines of context
556    ///
557    /// This operation is obsolete
558    #[arg(
559        long,
560        short = 'U',
561        value_name = "N",
562        requires = "by-hunk",
563        default_value_t = 3
564    )]
565    unified: usize,
566
567    /// Only review a specific hunk
568    ///
569    /// This operation is obsolete
570    #[arg(long, value_name = "INDEX", requires = "by-hunk")]
571    hunk: Option<usize>,
572
573    /// Accept a patch revision
574    #[arg(long, conflicts_with = "reject", conflicts_with = "delete")]
575    accept: bool,
576
577    /// Reject a patch revision
578    #[arg(long, conflicts_with = "delete")]
579    reject: bool,
580
581    /// Delete a review draft
582    ///
583    /// This operation is obsolete
584    #[arg(long, short)]
585    delete: bool,
586
587    #[clap(flatten)]
588    message_args: MessageArgs,
589}
590
591impl ReviewArgs {
592    fn as_operation(&self) -> review::Operation {
593        let Self {
594            patch,
595            accept,
596            reject,
597            delete,
598            ..
599        } = self;
600
601        if *patch {
602            let verdict = if *accept {
603                Some(Verdict::Accept)
604            } else if *reject {
605                Some(Verdict::Reject)
606            } else {
607                None
608            };
609            return review::Operation::Review(review::ReviewOptions {
610                by_hunk: true,
611                unified: self.unified,
612                hunk: self.hunk,
613                verdict,
614            });
615        }
616
617        if *delete {
618            return review::Operation::Delete;
619        }
620
621        if *accept {
622            return review::Operation::Review(review::ReviewOptions {
623                by_hunk: false,
624                unified: 3,
625                hunk: None,
626                verdict: Some(Verdict::Accept),
627            });
628        }
629
630        if *reject {
631            return review::Operation::Review(review::ReviewOptions {
632                by_hunk: false,
633                unified: 3,
634                hunk: None,
635                verdict: Some(Verdict::Reject),
636            });
637        }
638
639        panic!("expected one of `--patch`, `--delete`, `--accept`, or `--reject`");
640    }
641}
642
643impl From<ReviewArgs> for review::Options {
644    fn from(args: ReviewArgs) -> Self {
645        let op = args.as_operation();
646        Self {
647            message: Message::from(args.message_args),
648            op,
649        }
650    }
651}
652
653#[derive(Debug, clap::Args)]
654#[group(required = false, multiple = false)]
655pub(super) struct MessageArgs {
656    /// Provide a message (default: prompt)
657    ///
658    /// This can be specified multiple times. This will result in newlines
659    /// between the specified messages.
660    #[clap(
661        long,
662        short,
663        value_name = "MESSAGE",
664        num_args = 1..,
665        action = clap::ArgAction::Append
666    )]
667    pub(super) message: Option<Vec<String>>,
668
669    /// Do not provide a message
670    #[arg(long, conflicts_with = "message")]
671    pub(super) no_message: bool,
672}
673
674impl From<MessageArgs> for Message {
675    fn from(
676        MessageArgs {
677            message,
678            no_message,
679        }: MessageArgs,
680    ) -> Self {
681        if no_message {
682            assert!(message.is_none());
683            return Self::Blank;
684        }
685
686        match message {
687            Some(messages) => messages.into_iter().fold(Self::Blank, |mut result, m| {
688                result.append(&m);
689                result
690            }),
691            None => Self::Edit,
692        }
693    }
694}
695
696#[derive(Debug, clap::Args)]
697pub(super) struct CheckoutArgs {
698    /// Provide a name for the branch to checkout
699    #[arg(long, value_name = "BRANCH", value_parser = parse_refstr)]
700    pub(super) name: Option<RefString>,
701
702    /// Provide the git remote to use as the upstream
703    #[arg(long, value_parser = parse_refstr)]
704    pub(super) remote: Option<RefString>,
705
706    /// Checkout the head of the revision, even if the branch already exists
707    #[arg(long, short)]
708    pub(super) force: bool,
709}
710
711impl From<CheckoutArgs> for checkout::Options {
712    fn from(value: CheckoutArgs) -> Self {
713        Self {
714            name: value.name,
715            remote: value.remote,
716            force: value.force,
717        }
718    }
719}
720
721#[derive(Parser, Debug)]
722#[group(required = true)]
723pub(super) struct AssignArgs {
724    /// Add an assignee to the patch (may be specified multiple times).
725    ///
726    /// Note: `--add` takes precedence over `--delete`
727    #[arg(long, short, value_name = "DID", num_args = 1.., action = clap::ArgAction::Append)]
728    pub(super) add: Vec<Did>,
729
730    /// Remove an assignee from the patch (may be specified multiple times).
731    ///
732    /// Note: `--add` takes precedence over `--delete`
733    #[clap(long, short, value_name = "DID", num_args = 1.., action = clap::ArgAction::Append)]
734    pub(super) delete: Vec<Did>,
735}
736
737#[derive(Parser, Debug)]
738#[group(required = true)]
739pub(super) struct LabelArgs {
740    /// Add a label to the patch (may be specified multiple times).
741    ///
742    /// Note: `--add` takes precedence over `--delete`
743    #[arg(long, short, value_name = "LABEL", num_args = 1.., action = clap::ArgAction::Append)]
744    pub(super) add: Vec<Label>,
745
746    /// Remove a label from the patch (may be specified multiple times).
747    ///
748    /// Note: `--add` takes precedence over `--delete`
749    #[clap(long, short, value_name = "LABEL", num_args = 1.., action = clap::ArgAction::Append)]
750    pub(super) delete: Vec<Label>,
751}
752
753fn parse_refstr(refstr: &str) -> Result<RefString, git::fmt::Error> {
754    RefString::try_from(refstr)
755}