radicle_cli/commands/
patch.rs

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