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