radicle_cli/commands/
patch.rs

1mod archive;
2mod assign;
3mod cache;
4mod checkout;
5mod comment;
6mod delete;
7mod diff;
8mod edit;
9mod label;
10mod list;
11mod react;
12mod ready;
13mod redact;
14mod resolve;
15mod review;
16mod show;
17mod update;
18
19use std::collections::BTreeSet;
20use std::ffi::OsString;
21use std::str::FromStr as _;
22
23use anyhow::anyhow;
24
25use radicle::cob::patch::PatchId;
26use radicle::cob::{patch, Label, Reaction};
27use radicle::git::RefString;
28use radicle::patch::cache::Patches as _;
29use radicle::storage::git::transport;
30use radicle::{prelude::*, Node};
31
32use crate::git::Rev;
33use crate::node;
34use crate::terminal as term;
35use crate::terminal::args::{string, Args, Error, Help};
36use crate::terminal::patch::Message;
37
38pub const HELP: Help = Help {
39    name: "patch",
40    description: "Manage patches",
41    version: env!("RADICLE_VERSION"),
42    usage: r#"
43Usage
44
45    rad patch [<option>...]
46    rad patch list [--all|--merged|--open|--archived|--draft|--authored] [--author <did>]... [<option>...]
47    rad patch show <patch-id> [<option>...]
48    rad patch diff <patch-id> [<option>...]
49    rad patch archive <patch-id> [--undo] [<option>...]
50    rad patch update <patch-id> [<option>...]
51    rad patch checkout <patch-id> [<option>...]
52    rad patch review <patch-id> [--accept | --reject] [-m [<string>]] [-d | --delete] [<option>...]
53    rad patch resolve <patch-id> [--review <review-id>] [--comment <comment-id>] [--unresolve] [<option>...]
54    rad patch delete <patch-id> [<option>...]
55    rad patch redact <revision-id> [<option>...]
56    rad patch react <patch-id | revision-id> [--react <emoji>] [<option>...]
57    rad patch assign <revision-id> [--add <did>] [--delete <did>] [<option>...]
58    rad patch label <revision-id> [--add <label>] [--delete <label>] [<option>...]
59    rad patch ready <patch-id> [--undo] [<option>...]
60    rad patch edit <patch-id> [<option>...]
61    rad patch set <patch-id> [<option>...]
62    rad patch comment <patch-id | revision-id> [<option>...]
63    rad patch cache [<patch-id>] [--storage] [<option>...]
64
65Show options
66
67    -p, --patch                Show the actual patch diff
68    -v, --verbose              Show additional information about the patch
69
70Diff options
71
72    -r, --revision <id>        The revision to diff (default: latest)
73
74Comment options
75
76    -m, --message <string>     Provide a comment message via the command-line
77        --reply-to <comment>   The comment to reply to
78        --edit <comment>       The comment to edit (use --message to edit with the provided message)
79        --react <comment>      The comment to react to
80        --emoji <char>         The emoji to react with when --react is used
81        --redact <comment>     The comment to redact
82
83Edit options
84
85    -m, --message [<string>]   Provide a comment message to the patch or revision (default: prompt)
86
87Review options
88
89    -r, --revision <id>        Review the given revision of the patch
90    -p, --patch                Review by patch hunks
91        --hunk <index>         Only review a specific hunk
92        --accept               Accept a patch or set of hunks
93        --reject               Reject a patch or set of hunks
94    -U, --unified <n>          Generate diffs with <n> lines of context instead of the usual three
95    -d, --delete               Delete a review draft
96    -m, --message [<string>]   Provide a comment with the review (default: prompt)
97
98Resolve options
99
100    --review <id>              The review id which the comment is under
101    --comment <id>             The comment to (un)resolve
102    --undo                     Unresolve the comment
103
104Assign options
105
106    -a, --add    <did>         Add an assignee to the patch (may be specified multiple times).
107                               Note: --add will take precedence over --delete
108
109    -d, --delete <did>         Delete an assignee from the patch (may be specified multiple times).
110                               Note: --add will take precedence over --delete
111
112Archive options
113
114        --undo                 Unarchive a patch
115
116Label options
117
118    -a, --add    <label>       Add a label to the patch (may be specified multiple times).
119                               Note: --add will take precedence over --delete
120
121    -d, --delete <label>       Delete a label from the patch (may be specified multiple times).
122                               Note: --add will take precedence over --delete
123
124Update options
125
126    -b, --base <revspec>       Provide a Git revision as the base commit
127    -m, --message [<string>]   Provide a comment message to the patch or revision (default: prompt)
128        --no-message           Leave the patch or revision comment message blank
129
130List options
131
132        --all                  Show all patches, including merged and archived patches
133        --archived             Show only archived patches
134        --merged               Show only merged patches
135        --open                 Show only open patches (default)
136        --draft                Show only draft patches
137        --authored             Show only patches that you have authored
138        --author <did>         Show only patched where the given user is an author
139                               (may be specified multiple times)
140
141Ready options
142
143        --undo                 Convert a patch back to a draft
144
145Checkout options
146
147        --revision <id>        Checkout the given revision of the patch
148        --name <string>        Provide a name for the branch to checkout
149        --remote <string>      Provide the git remote to use as the upstream
150    -f, --force                Checkout the head of the revision, even if the branch already exists
151
152Set options
153
154        --remote <string>      Provide the git remote to use as the upstream
155
156React options
157
158        --emoji <char>         The emoji to react to the patch or revision with
159
160Other options
161
162        --repo <rid>           Operate on the given repository (default: cwd)
163        --[no-]announce        Announce changes made to the network
164    -q, --quiet                Quiet output
165        --help                 Print help
166"#,
167};
168
169#[derive(Debug, Default, PartialEq, Eq)]
170pub enum OperationName {
171    Assign,
172    Show,
173    Diff,
174    Update,
175    Archive,
176    Delete,
177    Checkout,
178    Comment,
179    React,
180    Ready,
181    Review,
182    Resolve,
183    Label,
184    #[default]
185    List,
186    Edit,
187    Redact,
188    Set,
189    Cache,
190}
191
192#[derive(Debug, PartialEq, Eq)]
193pub enum CommentOperation {
194    Edit,
195    React,
196    Redact,
197}
198
199#[derive(Debug, Default, PartialEq, Eq)]
200pub struct AssignOptions {
201    pub add: BTreeSet<Did>,
202    pub delete: BTreeSet<Did>,
203}
204
205#[derive(Debug, Default, PartialEq, Eq)]
206pub struct LabelOptions {
207    pub add: BTreeSet<Label>,
208    pub delete: BTreeSet<Label>,
209}
210
211#[derive(Debug)]
212pub enum Operation {
213    Show {
214        patch_id: Rev,
215        diff: bool,
216        verbose: bool,
217    },
218    Diff {
219        patch_id: Rev,
220        revision_id: Option<Rev>,
221    },
222    Update {
223        patch_id: Rev,
224        base_id: Option<Rev>,
225        message: Message,
226    },
227    Archive {
228        patch_id: Rev,
229        undo: bool,
230    },
231    Ready {
232        patch_id: Rev,
233        undo: bool,
234    },
235    Delete {
236        patch_id: Rev,
237    },
238    Checkout {
239        patch_id: Rev,
240        revision_id: Option<Rev>,
241        opts: checkout::Options,
242    },
243    Comment {
244        revision_id: Rev,
245        message: Message,
246        reply_to: Option<Rev>,
247    },
248    CommentEdit {
249        revision_id: Rev,
250        comment_id: Rev,
251        message: Message,
252    },
253    CommentRedact {
254        revision_id: Rev,
255        comment_id: Rev,
256    },
257    CommentReact {
258        revision_id: Rev,
259        comment_id: Rev,
260        reaction: Reaction,
261        undo: bool,
262    },
263    React {
264        revision_id: Rev,
265        reaction: Reaction,
266        undo: bool,
267    },
268    Review {
269        patch_id: Rev,
270        revision_id: Option<Rev>,
271        opts: review::Options,
272    },
273    Resolve {
274        patch_id: Rev,
275        review_id: Rev,
276        comment_id: Rev,
277        undo: bool,
278    },
279    Assign {
280        patch_id: Rev,
281        opts: AssignOptions,
282    },
283    Label {
284        patch_id: Rev,
285        opts: LabelOptions,
286    },
287    List {
288        filter: Option<patch::Status>,
289    },
290    Edit {
291        patch_id: Rev,
292        revision_id: Option<Rev>,
293        message: Message,
294    },
295    Redact {
296        revision_id: Rev,
297    },
298    Set {
299        patch_id: Rev,
300        remote: Option<RefString>,
301    },
302    Cache {
303        patch_id: Option<Rev>,
304        storage: bool,
305    },
306}
307
308impl Operation {
309    fn is_announce(&self) -> bool {
310        match self {
311            Operation::Update { .. }
312            | Operation::Archive { .. }
313            | Operation::Ready { .. }
314            | Operation::Delete { .. }
315            | Operation::Comment { .. }
316            | Operation::CommentEdit { .. }
317            | Operation::CommentRedact { .. }
318            | Operation::CommentReact { .. }
319            | Operation::Review { .. }
320            | Operation::Resolve { .. }
321            | Operation::Assign { .. }
322            | Operation::Label { .. }
323            | Operation::Edit { .. }
324            | Operation::Redact { .. }
325            | Operation::React { .. }
326            | Operation::Set { .. } => true,
327            Operation::Show { .. }
328            | Operation::Diff { .. }
329            | Operation::Checkout { .. }
330            | Operation::List { .. }
331            | Operation::Cache { .. } => false,
332        }
333    }
334}
335
336#[derive(Debug)]
337pub struct Options {
338    pub op: Operation,
339    pub repo: Option<RepoId>,
340    pub announce: bool,
341    pub quiet: bool,
342    pub authored: bool,
343    pub authors: Vec<Did>,
344}
345
346impl Args for Options {
347    fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
348        use lexopt::prelude::*;
349
350        let mut parser = lexopt::Parser::from_args(args);
351        let mut op: Option<OperationName> = None;
352        let mut verbose = false;
353        let mut quiet = false;
354        let mut authored = false;
355        let mut authors = vec![];
356        let mut announce = true;
357        let mut patch_id = None;
358        let mut revision_id = None;
359        let mut review_id = None;
360        let mut comment_id = None;
361        let mut message = Message::default();
362        let mut filter = Some(patch::Status::Open);
363        let mut diff = false;
364        let mut undo = false;
365        let mut reaction: Option<Reaction> = None;
366        let mut reply_to: Option<Rev> = None;
367        let mut comment_op: Option<(CommentOperation, Rev)> = None;
368        let mut checkout_opts = checkout::Options::default();
369        let mut remote: Option<RefString> = None;
370        let mut assign_opts = AssignOptions::default();
371        let mut label_opts = LabelOptions::default();
372        let mut review_op = review::Operation::default();
373        let mut base_id = None;
374        let mut repo = None;
375        let mut cache_storage = false;
376
377        while let Some(arg) = parser.next()? {
378            match arg {
379                // Options.
380                Long("message") | Short('m') => {
381                    if message != Message::Blank {
382                        // We skip this code when `no-message` is specified.
383                        let txt: String = term::args::string(&parser.value()?);
384                        message.append(&txt);
385                    }
386                }
387                Long("no-message") => {
388                    message = Message::Blank;
389                }
390                Long("announce") => {
391                    announce = true;
392                }
393                Long("no-announce") => {
394                    announce = false;
395                }
396
397                // Show options.
398                Long("patch") | Short('p') if op == Some(OperationName::Show) => {
399                    diff = true;
400                }
401                Long("verbose") | Short('v') if op == Some(OperationName::Show) => {
402                    verbose = true;
403                }
404
405                // Ready options.
406                Long("undo") if op == Some(OperationName::Ready) => {
407                    undo = true;
408                }
409
410                // Archive options.
411                Long("undo") if op == Some(OperationName::Archive) => {
412                    undo = true;
413                }
414
415                // Update options.
416                Short('b') | Long("base") if op == Some(OperationName::Update) => {
417                    let val = parser.value()?;
418                    let rev = term::args::rev(&val)?;
419
420                    base_id = Some(rev);
421                }
422
423                // React options.
424                Long("emoji") if op == Some(OperationName::React) => {
425                    if let Some(emoji) = parser.value()?.to_str() {
426                        reaction =
427                            Some(Reaction::from_str(emoji).map_err(|_| anyhow!("invalid emoji"))?);
428                    }
429                }
430                Long("undo") if op == Some(OperationName::React) => {
431                    undo = true;
432                }
433
434                // Comment options.
435                Long("reply-to") if op == Some(OperationName::Comment) => {
436                    let val = parser.value()?;
437                    let rev = term::args::rev(&val)?;
438
439                    reply_to = Some(rev);
440                }
441
442                Long("edit") if op == Some(OperationName::Comment) => {
443                    let val = parser.value()?;
444                    let rev = term::args::rev(&val)?;
445
446                    comment_op = Some((CommentOperation::Edit, rev));
447                }
448
449                Long("react") if op == Some(OperationName::Comment) => {
450                    let val = parser.value()?;
451                    let rev = term::args::rev(&val)?;
452
453                    comment_op = Some((CommentOperation::React, rev));
454                }
455                Long("emoji")
456                    if op == Some(OperationName::Comment)
457                        && matches!(comment_op, Some((CommentOperation::React, _))) =>
458                {
459                    if let Some(emoji) = parser.value()?.to_str() {
460                        reaction =
461                            Some(Reaction::from_str(emoji).map_err(|_| anyhow!("invalid emoji"))?);
462                    }
463                }
464                Long("undo")
465                    if op == Some(OperationName::Comment)
466                        && matches!(comment_op, Some((CommentOperation::React, _))) =>
467                {
468                    undo = true;
469                }
470
471                Long("redact") if op == Some(OperationName::Comment) => {
472                    let val = parser.value()?;
473                    let rev = term::args::rev(&val)?;
474
475                    comment_op = Some((CommentOperation::Redact, rev));
476                }
477
478                // Edit options.
479                Long("revision") | Short('r') if op == Some(OperationName::Edit) => {
480                    let val = parser.value()?;
481                    let rev = term::args::rev(&val)?;
482
483                    revision_id = Some(rev);
484                }
485
486                // Review/diff options.
487                Long("revision") | Short('r')
488                    if op == Some(OperationName::Review) || op == Some(OperationName::Diff) =>
489                {
490                    let val = parser.value()?;
491                    let rev = term::args::rev(&val)?;
492
493                    revision_id = Some(rev);
494                }
495                Long("patch") | Short('p') if op == Some(OperationName::Review) => {
496                    if let review::Operation::Review { by_hunk, .. } = &mut review_op {
497                        *by_hunk = true;
498                    } else {
499                        return Err(arg.unexpected().into());
500                    }
501                }
502                Long("unified") | Short('U') if op == Some(OperationName::Review) => {
503                    if let review::Operation::Review { unified, .. } = &mut review_op {
504                        let val = parser.value()?;
505                        *unified = term::args::number(&val)?;
506                    } else {
507                        return Err(arg.unexpected().into());
508                    }
509                }
510                Long("hunk") if op == Some(OperationName::Review) => {
511                    if let review::Operation::Review { hunk, .. } = &mut review_op {
512                        let val = parser.value()?;
513                        let val = term::args::number(&val)
514                            .map_err(|e| anyhow!("invalid hunk value: {e}"))?;
515
516                        *hunk = Some(val);
517                    } else {
518                        return Err(arg.unexpected().into());
519                    }
520                }
521                Long("delete") | Short('d') if op == Some(OperationName::Review) => {
522                    review_op = review::Operation::Delete;
523                }
524                Long("accept") if op == Some(OperationName::Review) => {
525                    if let review::Operation::Review {
526                        verdict: verdict @ None,
527                        ..
528                    } = &mut review_op
529                    {
530                        *verdict = Some(patch::Verdict::Accept);
531                    } else {
532                        return Err(arg.unexpected().into());
533                    }
534                }
535                Long("reject") if op == Some(OperationName::Review) => {
536                    if let review::Operation::Review {
537                        verdict: verdict @ None,
538                        ..
539                    } = &mut review_op
540                    {
541                        *verdict = Some(patch::Verdict::Reject);
542                    } else {
543                        return Err(arg.unexpected().into());
544                    }
545                }
546
547                // Resolve options
548                Long("undo") if op == Some(OperationName::Resolve) => {
549                    undo = true;
550                }
551                Long("review") if op == Some(OperationName::Resolve) => {
552                    let val = parser.value()?;
553                    let rev = term::args::rev(&val)?;
554
555                    review_id = Some(rev);
556                }
557                Long("comment") if op == Some(OperationName::Resolve) => {
558                    let val = parser.value()?;
559                    let rev = term::args::rev(&val)?;
560
561                    comment_id = Some(rev);
562                }
563
564                // Checkout options
565                Long("revision") if op == Some(OperationName::Checkout) => {
566                    let val = parser.value()?;
567                    let rev = term::args::rev(&val)?;
568
569                    revision_id = Some(rev);
570                }
571
572                Long("force") | Short('f') if op == Some(OperationName::Checkout) => {
573                    checkout_opts.force = true;
574                }
575
576                Long("name") if op == Some(OperationName::Checkout) => {
577                    let val = parser.value()?;
578                    checkout_opts.name = Some(term::args::refstring("name", val)?);
579                }
580
581                Long("remote") if op == Some(OperationName::Checkout) => {
582                    let val = parser.value()?;
583                    checkout_opts.remote = Some(term::args::refstring("remote", val)?);
584                }
585
586                // Assign options.
587                Short('a') | Long("add") if matches!(op, Some(OperationName::Assign)) => {
588                    assign_opts.add.insert(term::args::did(&parser.value()?)?);
589                }
590
591                Short('d') | Long("delete") if matches!(op, Some(OperationName::Assign)) => {
592                    assign_opts
593                        .delete
594                        .insert(term::args::did(&parser.value()?)?);
595                }
596
597                // Label options.
598                Short('a') | Long("add") if matches!(op, Some(OperationName::Label)) => {
599                    let val = parser.value()?;
600                    let name = term::args::string(&val);
601                    let label = Label::new(name)?;
602
603                    label_opts.add.insert(label);
604                }
605
606                Short('d') | Long("delete") if matches!(op, Some(OperationName::Label)) => {
607                    let val = parser.value()?;
608                    let name = term::args::string(&val);
609                    let label = Label::new(name)?;
610
611                    label_opts.delete.insert(label);
612                }
613
614                // Set options.
615                Long("remote") if op == Some(OperationName::Set) => {
616                    let val = parser.value()?;
617                    remote = Some(term::args::refstring("remote", val)?);
618                }
619
620                // List options.
621                Long("all") => {
622                    filter = None;
623                }
624                Long("draft") => {
625                    filter = Some(patch::Status::Draft);
626                }
627                Long("archived") => {
628                    filter = Some(patch::Status::Archived);
629                }
630                Long("merged") => {
631                    filter = Some(patch::Status::Merged);
632                }
633                Long("open") => {
634                    filter = Some(patch::Status::Open);
635                }
636                Long("authored") => {
637                    authored = true;
638                }
639                Long("author") if op == Some(OperationName::List) => {
640                    authors.push(term::args::did(&parser.value()?)?);
641                }
642
643                // Cache options.
644                Long("storage") if op == Some(OperationName::Cache) => {
645                    cache_storage = true;
646                }
647
648                // Common.
649                Long("quiet") | Short('q') => {
650                    quiet = true;
651                }
652                Long("repo") => {
653                    let val = parser.value()?;
654                    let rid = term::args::rid(&val)?;
655
656                    repo = Some(rid);
657                }
658                Long("help") => {
659                    return Err(Error::HelpManual { name: "rad-patch" }.into());
660                }
661                Short('h') => {
662                    return Err(Error::Help.into());
663                }
664
665                Value(val) if op.is_none() => match val.to_string_lossy().as_ref() {
666                    "l" | "list" => op = Some(OperationName::List),
667                    "s" | "show" => op = Some(OperationName::Show),
668                    "u" | "update" => op = Some(OperationName::Update),
669                    "d" | "delete" => op = Some(OperationName::Delete),
670                    "c" | "checkout" => op = Some(OperationName::Checkout),
671                    "a" | "archive" => op = Some(OperationName::Archive),
672                    "y" | "ready" => op = Some(OperationName::Ready),
673                    "e" | "edit" => op = Some(OperationName::Edit),
674                    "r" | "redact" => op = Some(OperationName::Redact),
675                    "diff" => op = Some(OperationName::Diff),
676                    "assign" => op = Some(OperationName::Assign),
677                    "label" => op = Some(OperationName::Label),
678                    "comment" => op = Some(OperationName::Comment),
679                    "review" => op = Some(OperationName::Review),
680                    "resolve" => op = Some(OperationName::Resolve),
681                    "set" => op = Some(OperationName::Set),
682                    "cache" => op = Some(OperationName::Cache),
683                    unknown => anyhow::bail!("unknown operation '{}'", unknown),
684                },
685                Value(val) if op == Some(OperationName::Redact) => {
686                    let rev = term::args::rev(&val)?;
687                    revision_id = Some(rev);
688                }
689                Value(val)
690                    if patch_id.is_none()
691                        && [
692                            Some(OperationName::Show),
693                            Some(OperationName::Diff),
694                            Some(OperationName::Update),
695                            Some(OperationName::Delete),
696                            Some(OperationName::Archive),
697                            Some(OperationName::Ready),
698                            Some(OperationName::Checkout),
699                            Some(OperationName::Comment),
700                            Some(OperationName::Review),
701                            Some(OperationName::Resolve),
702                            Some(OperationName::Edit),
703                            Some(OperationName::Set),
704                            Some(OperationName::Assign),
705                            Some(OperationName::Label),
706                            Some(OperationName::Cache),
707                        ]
708                        .contains(&op) =>
709                {
710                    let val = string(&val);
711                    patch_id = Some(Rev::from(val));
712                }
713                _ => anyhow::bail!(arg.unexpected()),
714            }
715        }
716
717        let op = match op.unwrap_or_default() {
718            OperationName::List => Operation::List { filter },
719            OperationName::Show => Operation::Show {
720                patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?,
721                diff,
722                verbose,
723            },
724            OperationName::Diff => Operation::Diff {
725                patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?,
726                revision_id,
727            },
728            OperationName::Delete => Operation::Delete {
729                patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?,
730            },
731            OperationName::Update => Operation::Update {
732                patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?,
733                base_id,
734                message,
735            },
736            OperationName::Archive => Operation::Archive {
737                patch_id: patch_id.ok_or_else(|| anyhow!("a patch id must be provided"))?,
738                undo,
739            },
740            OperationName::Checkout => Operation::Checkout {
741                patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?,
742                revision_id,
743                opts: checkout_opts,
744            },
745            OperationName::Comment => match comment_op {
746                Some((CommentOperation::Edit, comment)) => Operation::CommentEdit {
747                    revision_id: patch_id
748                        .ok_or_else(|| anyhow!("a patch or revision must be provided"))?,
749                    comment_id: comment,
750                    message,
751                },
752                Some((CommentOperation::React, comment)) => Operation::CommentReact {
753                    revision_id: patch_id
754                        .ok_or_else(|| anyhow!("a patch or revision must be provided"))?,
755                    comment_id: comment,
756                    reaction: reaction
757                        .ok_or_else(|| anyhow!("a reaction emoji must be provided"))?,
758                    undo,
759                },
760                Some((CommentOperation::Redact, comment)) => Operation::CommentRedact {
761                    revision_id: patch_id
762                        .ok_or_else(|| anyhow!("a patch or revision must be provided"))?,
763                    comment_id: comment,
764                },
765                None => Operation::Comment {
766                    revision_id: patch_id
767                        .ok_or_else(|| anyhow!("a patch or revision must be provided"))?,
768                    message,
769                    reply_to,
770                },
771            },
772            OperationName::React => Operation::React {
773                revision_id: patch_id
774                    .ok_or_else(|| anyhow!("a patch or revision must be provided"))?,
775                reaction: reaction.ok_or_else(|| anyhow!("a reaction emoji must be provided"))?,
776                undo,
777            },
778            OperationName::Review => Operation::Review {
779                patch_id: patch_id
780                    .ok_or_else(|| anyhow!("a patch or revision must be provided"))?,
781                revision_id,
782                opts: review::Options {
783                    message,
784                    op: review_op,
785                },
786            },
787            OperationName::Resolve => Operation::Resolve {
788                patch_id: patch_id
789                    .ok_or_else(|| anyhow!("a patch or revision must be provided"))?,
790                review_id: review_id.ok_or_else(|| anyhow!("a review must be provided"))?,
791                comment_id: comment_id.ok_or_else(|| anyhow!("a comment must be provided"))?,
792                undo,
793            },
794            OperationName::Ready => Operation::Ready {
795                patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?,
796                undo,
797            },
798            OperationName::Edit => Operation::Edit {
799                patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?,
800                revision_id,
801                message,
802            },
803            OperationName::Redact => Operation::Redact {
804                revision_id: revision_id.ok_or_else(|| anyhow!("a revision must be provided"))?,
805            },
806            OperationName::Assign => Operation::Assign {
807                patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?,
808                opts: assign_opts,
809            },
810            OperationName::Label => Operation::Label {
811                patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?,
812                opts: label_opts,
813            },
814            OperationName::Set => Operation::Set {
815                patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?,
816                remote,
817            },
818            OperationName::Cache => Operation::Cache {
819                patch_id,
820                storage: cache_storage,
821            },
822        };
823
824        Ok((
825            Options {
826                op,
827                repo,
828                quiet,
829                announce,
830                authored,
831                authors,
832            },
833            vec![],
834        ))
835    }
836}
837
838pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
839    let (workdir, rid) = if let Some(rid) = options.repo {
840        (None, rid)
841    } else {
842        radicle::rad::cwd()
843            .map(|(workdir, rid)| (Some(workdir), rid))
844            .map_err(|_| anyhow!("this command must be run in the context of a repository"))?
845    };
846
847    let profile = ctx.profile()?;
848    let repository = profile.storage.repository(rid)?;
849    let announce = options.announce && options.op.is_announce();
850
851    transport::local::register(profile.storage.clone());
852
853    match options.op {
854        Operation::List { filter } => {
855            let mut authors: BTreeSet<Did> = options.authors.iter().cloned().collect();
856            if options.authored {
857                authors.insert(profile.did());
858            }
859            list::run(filter.as_ref(), authors, &repository, &profile)?;
860        }
861        Operation::Show {
862            patch_id,
863            diff,
864            verbose,
865        } => {
866            let patch_id = patch_id.resolve(&repository.backend)?;
867            show::run(
868                &patch_id,
869                diff,
870                verbose,
871                &profile,
872                &repository,
873                workdir.as_ref(),
874            )?;
875        }
876        Operation::Diff {
877            patch_id,
878            revision_id,
879        } => {
880            let patch_id = patch_id.resolve(&repository.backend)?;
881            let revision_id = revision_id
882                .map(|rev| rev.resolve::<radicle::git::Oid>(&repository.backend))
883                .transpose()?
884                .map(patch::RevisionId::from);
885            diff::run(&patch_id, revision_id, &repository, &profile)?;
886        }
887        Operation::Update {
888            ref patch_id,
889            ref base_id,
890            ref message,
891        } => {
892            let patch_id = patch_id.resolve(&repository.backend)?;
893            let base_id = base_id
894                .as_ref()
895                .map(|base| base.resolve(&repository.backend))
896                .transpose()?;
897            let workdir = workdir.ok_or(anyhow!(
898                "this command must be run from a repository checkout"
899            ))?;
900
901            update::run(
902                patch_id,
903                base_id,
904                message.clone(),
905                &profile,
906                &repository,
907                &workdir,
908            )?;
909        }
910        Operation::Archive { ref patch_id, undo } => {
911            let patch_id = patch_id.resolve::<PatchId>(&repository.backend)?;
912            archive::run(&patch_id, undo, &profile, &repository)?;
913        }
914        Operation::Ready { ref patch_id, undo } => {
915            let patch_id = patch_id.resolve::<PatchId>(&repository.backend)?;
916
917            if !ready::run(&patch_id, undo, &profile, &repository)? {
918                if undo {
919                    anyhow::bail!("the patch must be open to be put in draft state");
920                } else {
921                    anyhow::bail!("this patch must be in draft state to be put in open state");
922                }
923            }
924        }
925        Operation::Delete { patch_id } => {
926            let patch_id = patch_id.resolve::<PatchId>(&repository.backend)?;
927            delete::run(&patch_id, &profile, &repository)?;
928        }
929        Operation::Checkout {
930            patch_id,
931            revision_id,
932            opts,
933        } => {
934            let patch_id = patch_id.resolve::<radicle::git::Oid>(&repository.backend)?;
935            let revision_id = revision_id
936                .map(|rev| rev.resolve::<radicle::git::Oid>(&repository.backend))
937                .transpose()?
938                .map(patch::RevisionId::from);
939            let workdir = workdir.ok_or(anyhow!(
940                "this command must be run from a repository checkout"
941            ))?;
942            checkout::run(
943                &patch::PatchId::from(patch_id),
944                revision_id,
945                &repository,
946                &workdir,
947                &profile,
948                opts,
949            )?;
950        }
951        Operation::Comment {
952            revision_id,
953            message,
954            reply_to,
955        } => {
956            comment::run(
957                revision_id,
958                message,
959                reply_to,
960                options.quiet,
961                &repository,
962                &profile,
963            )?;
964        }
965        Operation::Review {
966            patch_id,
967            revision_id,
968            opts,
969        } => {
970            let patch_id = patch_id.resolve(&repository.backend)?;
971            let revision_id = revision_id
972                .map(|rev| rev.resolve::<radicle::git::Oid>(&repository.backend))
973                .transpose()?
974                .map(patch::RevisionId::from);
975            review::run(patch_id, revision_id, opts, &profile, &repository)?;
976        }
977        Operation::Resolve {
978            ref patch_id,
979            ref review_id,
980            ref comment_id,
981            undo,
982        } => {
983            let patch = patch_id.resolve(&repository.backend)?;
984            let review = patch::ReviewId::from(
985                review_id.resolve::<radicle::cob::EntryId>(&repository.backend)?,
986            );
987            let comment = comment_id.resolve(&repository.backend)?;
988            if undo {
989                resolve::unresolve(patch, review, comment, &repository, &profile)?;
990                term::success!("Unresolved comment {comment_id}");
991            } else {
992                resolve::resolve(patch, review, comment, &repository, &profile)?;
993                term::success!("Resolved comment {comment_id}");
994            }
995        }
996        Operation::Edit {
997            patch_id,
998            revision_id,
999            message,
1000        } => {
1001            let patch_id = patch_id.resolve(&repository.backend)?;
1002            let revision_id = revision_id
1003                .map(|id| id.resolve::<radicle::git::Oid>(&repository.backend))
1004                .transpose()?
1005                .map(patch::RevisionId::from);
1006            edit::run(&patch_id, revision_id, message, &profile, &repository)?;
1007        }
1008        Operation::Redact { revision_id } => {
1009            redact::run(&revision_id, &profile, &repository)?;
1010        }
1011        Operation::Assign {
1012            patch_id,
1013            opts: AssignOptions { add, delete },
1014        } => {
1015            let patch_id = patch_id.resolve(&repository.backend)?;
1016            assign::run(&patch_id, add, delete, &profile, &repository)?;
1017        }
1018        Operation::Label {
1019            patch_id,
1020            opts: LabelOptions { add, delete },
1021        } => {
1022            let patch_id = patch_id.resolve(&repository.backend)?;
1023            label::run(&patch_id, add, delete, &profile, &repository)?;
1024        }
1025        Operation::Set { patch_id, remote } => {
1026            let patches = term::cob::patches(&profile, &repository)?;
1027            let patch_id = patch_id.resolve(&repository.backend)?;
1028            let patch = patches
1029                .get(&patch_id)?
1030                .ok_or_else(|| anyhow!("patch {patch_id} not found"))?;
1031            let workdir = workdir.ok_or(anyhow!(
1032                "this command must be run from a repository checkout"
1033            ))?;
1034            radicle::rad::setup_patch_upstream(
1035                &patch_id,
1036                *patch.head(),
1037                &workdir,
1038                remote.as_ref().unwrap_or(&radicle::rad::REMOTE_NAME),
1039                true,
1040            )?;
1041        }
1042        Operation::Cache { patch_id, storage } => {
1043            let mode = if storage {
1044                cache::CacheMode::Storage
1045            } else {
1046                let patch_id = patch_id
1047                    .map(|id| id.resolve(&repository.backend))
1048                    .transpose()?;
1049                patch_id.map_or(
1050                    cache::CacheMode::Repository {
1051                        repository: &repository,
1052                    },
1053                    |id| cache::CacheMode::Patch {
1054                        id,
1055                        repository: &repository,
1056                    },
1057                )
1058            };
1059            cache::run(mode, &profile)?;
1060        }
1061        Operation::CommentEdit {
1062            revision_id,
1063            comment_id,
1064            message,
1065        } => {
1066            let comment = comment_id.resolve(&repository.backend)?;
1067            comment::edit::run(
1068                revision_id,
1069                comment,
1070                message,
1071                options.quiet,
1072                &repository,
1073                &profile,
1074            )?;
1075        }
1076        Operation::CommentRedact {
1077            revision_id,
1078            comment_id,
1079        } => {
1080            let comment = comment_id.resolve(&repository.backend)?;
1081            comment::redact::run(revision_id, comment, &repository, &profile)?;
1082        }
1083        Operation::CommentReact {
1084            revision_id,
1085            comment_id,
1086            reaction,
1087            undo,
1088        } => {
1089            let comment = comment_id.resolve(&repository.backend)?;
1090            if undo {
1091                comment::react::run(revision_id, comment, reaction, false, &repository, &profile)?;
1092            } else {
1093                comment::react::run(revision_id, comment, reaction, true, &repository, &profile)?;
1094            }
1095        }
1096        Operation::React {
1097            revision_id,
1098            reaction,
1099            undo,
1100        } => {
1101            if undo {
1102                react::run(&revision_id, reaction, false, &repository, &profile)?;
1103            } else {
1104                react::run(&revision_id, reaction, true, &repository, &profile)?;
1105            }
1106        }
1107    }
1108
1109    if announce {
1110        let mut node = Node::new(profile.socket());
1111        node::announce(
1112            &repository,
1113            node::SyncSettings::default(),
1114            node::SyncReporting::default(),
1115            &mut node,
1116            &profile,
1117        )?;
1118    }
1119    Ok(())
1120}