radicle_cli/commands/issue/
args.rs

1use std::str::FromStr;
2
3use clap::{Parser, Subcommand};
4
5use radicle::{
6    cob::{Label, Reaction, Title},
7    identity::{did::DidError, Did, RepoId},
8    issue::{CloseReason, State},
9};
10
11use crate::{git::Rev, terminal::patch::Message};
12
13#[derive(Default, Debug, Clone, PartialEq, Eq)]
14pub enum Assigned {
15    #[default]
16    Me,
17    Peer(Did),
18}
19
20#[derive(Parser, Debug)]
21#[command(about = super::ABOUT, disable_version_flag = true)]
22pub struct Args {
23    #[command(subcommand)]
24    pub(crate) command: Option<Command>,
25
26    /// Do not print anything
27    #[arg(short, long)]
28    #[clap(global = true)]
29    pub(crate) quiet: bool,
30
31    /// Do not announce issue changes to the network
32    #[arg(long)]
33    #[arg(value_name = "no-announce")]
34    #[clap(global = true)]
35    pub(crate) no_announce: bool,
36
37    /// Show only the issue header, hiding the comments
38    #[arg(long)]
39    #[clap(global = true)]
40    pub(crate) header: bool,
41
42    /// Operate on the given repository (default: cwd)
43    #[arg(value_name = "RID")]
44    #[arg(long, short)]
45    #[clap(global = true)]
46    pub(crate) repo: Option<RepoId>,
47
48    /// Enable verbose output
49    #[arg(long, short)]
50    #[clap(global = true)]
51    pub(crate) verbose: bool,
52
53    /// Arguments for the empty subcommand.
54    /// Will fall back to [`Command::List`].
55    #[clap(flatten)]
56    pub(crate) empty: EmptyArgs,
57}
58
59#[derive(Subcommand, Debug)]
60pub(crate) enum Command {
61    /// Add or delete assignees from an issue
62    Assign {
63        /// ID of the issue
64        #[arg(value_name = "ISSUE_ID")]
65        id: Rev,
66
67        /// Add an assignee (may be specified multiple times, takes precedence over `--delete`)
68        #[arg(long, short)]
69        #[arg(value_name = "DID")]
70        #[arg(action = clap::ArgAction::Append)]
71        add: Vec<Did>,
72
73        /// Delete an assignee (may be specified multiple times)
74        #[arg(long, short)]
75        #[arg(value_name = "DID")]
76        #[arg(action = clap::ArgAction::Append)]
77        delete: Vec<Did>,
78    },
79    /// Re-cache all issues that can be found in Radicle storage
80    Cache {
81        /// Optionally choose an issue to re-cache
82        #[arg(value_name = "ISSUE_ID")]
83        id: Option<Rev>,
84
85        /// Operate on storage
86        #[arg(long)]
87        storage: bool,
88    },
89    /// Add a comment to an issue
90    #[clap(long_about = include_str!("comment.txt"))]
91    Comment(CommentArgs),
92    /// Edit the title and description of an issue
93    Edit {
94        /// ID of the issue
95        #[arg(value_name = "ISSUE_ID")]
96        id: Rev,
97
98        /// The new title to set
99        #[arg(long, short)]
100        title: Option<Title>,
101
102        /// The new description to set
103        #[arg(long, short)]
104        description: Option<String>,
105    },
106    /// Delete an issue
107    Delete {
108        /// ID of the issue
109        #[arg(value_name = "ISSUE_ID")]
110        id: Rev,
111    },
112    /// Add or delete labels from an issue
113    Label {
114        /// ID of the issue
115        #[arg(value_name = "ISSUE_ID")]
116        id: Rev,
117
118        /// Add a label (may be specified multiple times, takes precedence over `--delete`)
119        #[arg(long, short)]
120        #[arg(value_name = "label")]
121        #[arg(action = clap::ArgAction::Append)]
122        add: Vec<Label>,
123
124        /// Delete a label (may be specified multiple times)
125        #[arg(long, short)]
126        #[arg(value_name = "label")]
127        #[arg(action = clap::ArgAction::Append)]
128        delete: Vec<Label>,
129    },
130    /// List issues, optionally filtering them
131    List(ListArgs),
132    /// Open a new issue
133    Open {
134        /// The title of the issue
135        #[arg(long, short)]
136        title: Option<Title>,
137
138        /// The description of the issue
139        #[arg(long, short)]
140        description: Option<String>,
141
142        /// A set of labels to associate with the issue
143        #[arg(long)]
144        labels: Vec<Label>,
145
146        /// A set of DIDs to assign to the issue
147        #[arg(value_name = "DID")]
148        #[arg(long)]
149        assignees: Vec<Did>,
150    },
151    /// Add a reaction emoji to an issue or comment
152    React {
153        /// ID of the issue
154        #[arg(value_name = "ISSUE_ID")]
155        id: Rev,
156
157        /// The emoji reaction
158        #[arg(long = "emoji")]
159        #[arg(value_name = "CHAR")]
160        reaction: Option<Reaction>,
161
162        /// Optionally react to a comment
163        #[arg(long = "to")]
164        #[arg(value_name = "COMMENT_ID")]
165        comment_id: Option<Rev>,
166    },
167    /// Show a specific issue
168    Show {
169        /// ID of the issue
170        #[arg(value_name = "ISSUE_ID")]
171        id: Rev,
172    },
173    /// Transition the state of an issue
174    State {
175        /// ID of the issue
176        #[arg(value_name = "ISSUE_ID")]
177        id: Rev,
178
179        /// The desired target state
180        #[clap(flatten)]
181        target_state: StateArgs,
182    },
183}
184
185impl Command {
186    /// Returns `true` if the changes made by the command should announce to the
187    /// network.
188    pub(crate) fn should_announce_for(&self) -> bool {
189        match self {
190            Command::Open { .. }
191            | Command::React { .. }
192            | Command::State { .. }
193            | Command::Delete { .. }
194            | Command::Assign { .. }
195            | Command::Label { .. }
196            // Special handling for `--edit` will be removed in the future.
197            | Command::Edit { .. } => true,
198            Command::Comment(args) => !args.is_edit(),
199            _ => false,
200        }
201    }
202}
203
204/// Arguments for the empty subcommand.
205#[derive(Parser, Debug, Default)]
206pub(crate) struct EmptyArgs {
207    #[arg(long, name = "DID")]
208    #[arg(default_missing_value = "me")]
209    #[arg(num_args = 0..=1)]
210    #[arg(hide = true)]
211    pub(crate) assigned: Option<Assigned>,
212
213    #[clap(flatten)]
214    pub(crate) state: EmptyStateArgs,
215}
216
217/// Counterpart to [`ListStateArgs`] for the empty subcommand.
218#[derive(Parser, Debug, Default)]
219#[group(multiple = false)]
220pub(crate) struct EmptyStateArgs {
221    #[arg(long, hide = true)]
222    all: bool,
223
224    #[arg(long, hide = true)]
225    open: bool,
226
227    #[arg(long, hide = true)]
228    closed: bool,
229
230    #[arg(long, hide = true)]
231    solved: bool,
232}
233
234/// Arguments for the [`Command::List`] subcommand.
235#[derive(Parser, Debug, Default)]
236pub(crate) struct ListArgs {
237    /// Filter for the list of issues that are assigned to '<DID>' (default: me)
238    #[arg(long, name = "DID")]
239    #[arg(default_missing_value = "me")]
240    #[arg(num_args = 0..=1)]
241    pub(crate) assigned: Option<Assigned>,
242
243    #[clap(flatten)]
244    pub(crate) state: ListStateArgs,
245}
246
247#[derive(Parser, Debug, Default)]
248#[group(multiple = false)]
249pub(crate) struct ListStateArgs {
250    /// List all issues
251    #[arg(long)]
252    all: bool,
253
254    /// List only open issues (default)
255    #[arg(long)]
256    open: bool,
257
258    /// List only closed issues
259    #[arg(long)]
260    closed: bool,
261
262    /// List only solved issues
263    #[arg(long)]
264    solved: bool,
265}
266
267impl From<&ListStateArgs> for Option<State> {
268    fn from(args: &ListStateArgs) -> Self {
269        match (args.all, args.open, args.closed, args.solved) {
270            (true, false, false, false) => None,
271            (false, true, false, false) | (false, false, false, false) => Some(State::Open),
272            (false, false, true, false) => Some(State::Closed {
273                reason: CloseReason::Other,
274            }),
275            (false, false, false, true) => Some(State::Closed {
276                reason: CloseReason::Solved,
277            }),
278            _ => unreachable!(),
279        }
280    }
281}
282
283impl From<EmptyStateArgs> for ListStateArgs {
284    fn from(args: EmptyStateArgs) -> Self {
285        Self {
286            all: args.all,
287            open: args.open,
288            closed: args.closed,
289            solved: args.solved,
290        }
291    }
292}
293
294impl From<EmptyArgs> for ListArgs {
295    fn from(args: EmptyArgs) -> Self {
296        Self {
297            assigned: args.assigned,
298            state: ListStateArgs::from(args.state),
299        }
300    }
301}
302
303/// Arguments for the [`Command::Comment`] subcommand.
304#[derive(Parser, Debug)]
305pub(crate) struct CommentArgs {
306    /// ID of the issue
307    #[arg(value_name = "ISSUE_ID")]
308    id: Rev,
309
310    /// The body of the comment
311    #[arg(long, short)]
312    #[arg(value_name = "MESSAGE")]
313    message: Message,
314
315    /// Optionally, the comment to reply to. If not specified, the comment
316    /// will be in reply to the issue itself
317    #[arg(long, value_name = "COMMENT_ID")]
318    #[arg(conflicts_with = "edit")]
319    reply_to: Option<Rev>,
320
321    /// Edit a comment by specifying its ID
322    #[arg(long, value_name = "COMMENT_ID")]
323    #[arg(conflicts_with = "reply_to")]
324    edit: Option<Rev>,
325}
326
327impl CommentArgs {
328    // TODO(finto): this is only needed to avoid announcing edits for the time
329    // being
330    /// If the comment is editing an existing comment
331    pub(crate) fn is_edit(&self) -> bool {
332        self.edit.is_some()
333    }
334}
335
336/// Arguments for the [`Command::State`] subcommand.
337#[derive(Parser, Debug)]
338#[group(required = true, multiple = false)]
339pub(crate) struct StateArgs {
340    /// Change the state to 'open'
341    #[arg(long)]
342    pub(crate) open: bool,
343
344    /// Change the state to 'closed'
345    #[arg(long)]
346    pub(crate) closed: bool,
347
348    /// Change the state to 'solved'
349    #[arg(long)]
350    pub(crate) solved: bool,
351}
352
353impl From<StateArgs> for StateArg {
354    fn from(state: StateArgs) -> Self {
355        // These are mutually exclusive, guaranteed by clap grouping
356        match (state.open, state.closed, state.solved) {
357            (true, _, _) => StateArg::Open,
358            (_, true, _) => StateArg::Closed,
359            (_, _, true) => StateArg::Solved,
360            _ => unreachable!(),
361        }
362    }
363}
364
365/// Argument value for transition an issue to the given [`State`].
366#[derive(Clone, Copy, Debug)]
367pub(crate) enum StateArg {
368    /// Open issues.
369    /// Maps to [`State::Open`].
370    Open,
371    /// Closed issues.
372    /// Maps to [`State::Closed`] and [`CloseReason::Other`].
373    Closed,
374    /// Solved issues.
375    /// Maps to [`State::Closed`] and [`CloseReason::Solved`].
376    Solved,
377}
378
379impl From<StateArg> for State {
380    fn from(value: StateArg) -> Self {
381        match value {
382            StateArg::Open => Self::Open,
383            StateArg::Closed => Self::Closed {
384                reason: CloseReason::Other,
385            },
386            StateArg::Solved => Self::Closed {
387                reason: CloseReason::Solved,
388            },
389        }
390    }
391}
392
393impl FromStr for Assigned {
394    type Err = DidError;
395
396    fn from_str(s: &str) -> Result<Self, Self::Err> {
397        if s == "me" {
398            Ok(Assigned::Me)
399        } else {
400            let value = s.parse::<Did>()?;
401            Ok(Assigned::Peer(value))
402        }
403    }
404}
405
406/// The action that should be performed based on the supplied [`CommentArgs`].
407pub(crate) enum CommentAction {
408    /// Comment to the main issue thread.
409    Comment {
410        /// ID of the issue
411        id: Rev,
412        /// The message of the comment.
413        message: Message,
414    },
415    /// Reply to a specific comment in the issue.
416    Reply {
417        /// ID of the issue
418        id: Rev,
419        /// The message that is being used to reply to the comment.
420        message: Message,
421        /// The comment ID that is being replied to.
422        reply_to: Rev,
423    },
424    /// Edit a specific comment in the issue.
425    Edit {
426        /// ID of the issue
427        id: Rev,
428        /// The message that is being used to edit the comment.
429        message: Message,
430        /// The comment ID that is being edited.
431        to_edit: Rev,
432    },
433}
434
435impl From<CommentArgs> for CommentAction {
436    fn from(
437        CommentArgs {
438            id,
439            message,
440            reply_to,
441            edit,
442        }: CommentArgs,
443    ) -> Self {
444        match (reply_to, edit) {
445            (Some(_), Some(_)) => {
446                unreachable!("the argument '--reply-to' cannot be used with '--edit'")
447            }
448            (Some(reply_to), None) => Self::Reply {
449                id,
450                message,
451                reply_to,
452            },
453            (None, Some(to_edit)) => Self::Edit {
454                id,
455                message,
456                to_edit,
457            },
458            (None, None) => Self::Comment { id, message },
459        }
460    }
461}