Skip to main content

radicle_cli/commands/patch/
args.rs

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