radicle_cli/commands/
issue.rs

1#[path = "issue/cache.rs"]
2mod cache;
3
4use std::collections::BTreeSet;
5use std::ffi::OsString;
6use std::str::FromStr;
7
8use anyhow::{anyhow, Context as _};
9
10use radicle::cob::common::{Label, Reaction};
11use radicle::cob::issue::{CloseReason, State};
12use radicle::cob::{issue, thread};
13use radicle::crypto;
14use radicle::issue::cache::Issues as _;
15use radicle::node::device::Device;
16use radicle::node::NodeId;
17use radicle::prelude::{Did, RepoId};
18use radicle::profile;
19use radicle::storage;
20use radicle::storage::{ReadRepository, WriteRepository, WriteStorage};
21use radicle::Profile;
22use radicle::{cob, Node};
23
24use crate::git::Rev;
25use crate::node;
26use crate::terminal as term;
27use crate::terminal::args::{Args, Error, Help};
28use crate::terminal::format::Author;
29use crate::terminal::issue::Format;
30use crate::terminal::patch::Message;
31use crate::terminal::Element;
32
33pub const HELP: Help = Help {
34    name: "issue",
35    description: "Manage issues",
36    version: env!("RADICLE_VERSION"),
37    usage: r#"
38Usage
39
40    rad issue [<option>...]
41    rad issue delete <issue-id> [<option>...]
42    rad issue edit <issue-id> [--title <title>] [--description <text>] [<option>...]
43    rad issue list [--assigned <did>] [--all | --closed | --open | --solved] [<option>...]
44    rad issue open [--title <title>] [--description <text>] [--label <label>] [<option>...]
45    rad issue react <issue-id> [--emoji <char>] [--to <comment>] [<option>...]
46    rad issue assign <issue-id> [--add <did>] [--delete <did>] [<option>...]
47    rad issue label <issue-id> [--add <label>] [--delete <label>] [<option>...]
48    rad issue comment <issue-id> [--message <message>] [--reply-to <comment-id>] [--edit <comment-id>] [<option>...]
49    rad issue show <issue-id> [<option>...]
50    rad issue state <issue-id> [--closed | --open | --solved] [<option>...]
51    rad issue cache [<issue-id>] [--storage] [<option>...]
52
53Assign options
54
55    -a, --add    <did>     Add an assignee to the issue (may be specified multiple times).
56    -d, --delete <did>     Delete an assignee from the issue (may be specified multiple times).
57
58    Note: --add takes precedence over --delete
59
60Label options
61
62    -a, --add    <label>   Add a label to the issue (may be specified multiple times).
63    -d, --delete <label>   Delete a label from the issue (may be specified multiple times).
64
65    Note: --add takes precedence over --delete
66
67Show options
68
69        --debug                Show the issue as Rust debug output
70
71Options
72
73        --repo <rid>       Operate on the given repository (default: cwd)
74        --no-announce      Don't announce issue to peers
75        --header           Show only the issue header, hiding the comments
76    -q, --quiet            Don't print anything
77        --help             Print help
78"#,
79};
80
81#[derive(Default, Debug, PartialEq, Eq)]
82pub enum OperationName {
83    Assign,
84    Edit,
85    Open,
86    Comment,
87    Delete,
88    Label,
89    #[default]
90    List,
91    React,
92    Show,
93    State,
94    Cache,
95}
96
97/// Command line Peer argument.
98#[derive(Default, Debug, PartialEq, Eq)]
99pub enum Assigned {
100    #[default]
101    Me,
102    Peer(Did),
103}
104
105#[derive(Debug, PartialEq, Eq)]
106pub enum Operation {
107    Edit {
108        id: Rev,
109        title: Option<String>,
110        description: Option<String>,
111    },
112    Open {
113        title: Option<String>,
114        description: Option<String>,
115        labels: Vec<Label>,
116        assignees: Vec<Did>,
117    },
118    Show {
119        id: Rev,
120        format: Format,
121        debug: bool,
122    },
123    CommentEdit {
124        id: Rev,
125        comment_id: Rev,
126        message: Message,
127    },
128    Comment {
129        id: Rev,
130        message: Message,
131        reply_to: Option<Rev>,
132    },
133    State {
134        id: Rev,
135        state: State,
136    },
137    Delete {
138        id: Rev,
139    },
140    React {
141        id: Rev,
142        reaction: Option<Reaction>,
143        comment_id: Option<thread::CommentId>,
144    },
145    Assign {
146        id: Rev,
147        opts: AssignOptions,
148    },
149    Label {
150        id: Rev,
151        opts: LabelOptions,
152    },
153    List {
154        assigned: Option<Assigned>,
155        state: Option<State>,
156    },
157    Cache {
158        id: Option<Rev>,
159        storage: bool,
160    },
161}
162
163#[derive(Debug, Default, PartialEq, Eq)]
164pub struct AssignOptions {
165    pub add: BTreeSet<Did>,
166    pub delete: BTreeSet<Did>,
167}
168
169#[derive(Debug, Default, PartialEq, Eq)]
170pub struct LabelOptions {
171    pub add: BTreeSet<Label>,
172    pub delete: BTreeSet<Label>,
173}
174
175#[derive(Debug)]
176pub struct Options {
177    pub op: Operation,
178    pub repo: Option<RepoId>,
179    pub announce: bool,
180    pub quiet: bool,
181}
182
183impl Args for Options {
184    fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
185        use lexopt::prelude::*;
186
187        let mut parser = lexopt::Parser::from_args(args);
188        let mut op: Option<OperationName> = None;
189        let mut id: Option<Rev> = None;
190        let mut assigned: Option<Assigned> = None;
191        let mut title: Option<String> = None;
192        let mut reaction: Option<Reaction> = None;
193        let mut comment_id: Option<thread::CommentId> = None;
194        let mut description: Option<String> = None;
195        let mut state: Option<State> = Some(State::Open);
196        let mut labels = Vec::new();
197        let mut assignees = Vec::new();
198        let mut format = Format::default();
199        let mut message = Message::default();
200        let mut reply_to = None;
201        let mut edit_comment = None;
202        let mut announce = true;
203        let mut quiet = false;
204        let mut debug = false;
205        let mut assign_opts = AssignOptions::default();
206        let mut label_opts = LabelOptions::default();
207        let mut repo = None;
208        let mut cache_storage = false;
209
210        while let Some(arg) = parser.next()? {
211            match arg {
212                Long("help") | Short('h') => {
213                    return Err(Error::Help.into());
214                }
215
216                // List options.
217                Long("all") if op.is_none() || op == Some(OperationName::List) => {
218                    state = None;
219                }
220                Long("closed") if op.is_none() || op == Some(OperationName::List) => {
221                    state = Some(State::Closed {
222                        reason: CloseReason::Other,
223                    });
224                }
225                Long("open") if op.is_none() || op == Some(OperationName::List) => {
226                    state = Some(State::Open);
227                }
228                Long("solved") if op.is_none() || op == Some(OperationName::List) => {
229                    state = Some(State::Closed {
230                        reason: CloseReason::Solved,
231                    });
232                }
233
234                // Open/Edit options.
235                Long("title")
236                    if op == Some(OperationName::Open) || op == Some(OperationName::Edit) =>
237                {
238                    title = Some(parser.value()?.to_string_lossy().into());
239                }
240                Long("description")
241                    if op == Some(OperationName::Open) || op == Some(OperationName::Edit) =>
242                {
243                    description = Some(parser.value()?.to_string_lossy().into());
244                }
245                Short('l') | Long("label") if matches!(op, Some(OperationName::Open)) => {
246                    let val = parser.value()?;
247                    let name = term::args::string(&val);
248                    let label = Label::new(name)?;
249
250                    labels.push(label);
251                }
252                Long("assign") if op == Some(OperationName::Open) => {
253                    let val = parser.value()?;
254                    let did = term::args::did(&val)?;
255
256                    assignees.push(did);
257                }
258
259                // State options.
260                Long("closed") if op == Some(OperationName::State) => {
261                    state = Some(State::Closed {
262                        reason: CloseReason::Other,
263                    });
264                }
265                Long("open") if op == Some(OperationName::State) => {
266                    state = Some(State::Open);
267                }
268                Long("solved") if op == Some(OperationName::State) => {
269                    state = Some(State::Closed {
270                        reason: CloseReason::Solved,
271                    });
272                }
273
274                // React options.
275                Long("emoji") if op == Some(OperationName::React) => {
276                    if let Some(emoji) = parser.value()?.to_str() {
277                        reaction =
278                            Some(Reaction::from_str(emoji).map_err(|_| anyhow!("invalid emoji"))?);
279                    }
280                }
281                Long("to") if op == Some(OperationName::React) => {
282                    let oid: String = parser.value()?.to_string_lossy().into();
283                    comment_id = Some(oid.parse()?);
284                }
285
286                // Show options.
287                Long("format") if op == Some(OperationName::Show) => {
288                    let val = parser.value()?;
289                    let val = term::args::string(&val);
290
291                    match val.as_str() {
292                        "header" => format = Format::Header,
293                        "full" => format = Format::Full,
294                        _ => anyhow::bail!("unknown format '{val}'"),
295                    }
296                }
297                Long("debug") if op == Some(OperationName::Show) => {
298                    debug = true;
299                }
300
301                // Comment options.
302                Long("message") | Short('m') if op == Some(OperationName::Comment) => {
303                    let val = parser.value()?;
304                    let txt = term::args::string(&val);
305
306                    message.append(&txt);
307                }
308                Long("reply-to") if op == Some(OperationName::Comment) => {
309                    let val = parser.value()?;
310                    let rev = term::args::rev(&val)?;
311
312                    reply_to = Some(rev);
313                }
314                Long("edit") if op == Some(OperationName::Comment) => {
315                    let val = parser.value()?;
316                    let rev = term::args::rev(&val)?;
317
318                    edit_comment = Some(rev);
319                }
320
321                // Assign options
322                Short('a') | Long("add") if op == Some(OperationName::Assign) => {
323                    assign_opts.add.insert(term::args::did(&parser.value()?)?);
324                }
325                Short('d') | Long("delete") if op == Some(OperationName::Assign) => {
326                    assign_opts
327                        .delete
328                        .insert(term::args::did(&parser.value()?)?);
329                }
330                Long("assigned") | Short('a') if assigned.is_none() => {
331                    if let Ok(val) = parser.value() {
332                        let peer = term::args::did(&val)?;
333                        assigned = Some(Assigned::Peer(peer));
334                    } else {
335                        assigned = Some(Assigned::Me);
336                    }
337                }
338
339                // Label options
340                Short('a') | Long("add") if matches!(op, Some(OperationName::Label)) => {
341                    let val = parser.value()?;
342                    let name = term::args::string(&val);
343                    let label = Label::new(name)?;
344
345                    label_opts.add.insert(label);
346                }
347                Short('d') | Long("delete") if matches!(op, Some(OperationName::Label)) => {
348                    let val = parser.value()?;
349                    let name = term::args::string(&val);
350                    let label = Label::new(name)?;
351
352                    label_opts.delete.insert(label);
353                }
354
355                // Cache options.
356                Long("storage") if matches!(op, Some(OperationName::Cache)) => {
357                    cache_storage = true;
358                }
359
360                // Options.
361                Long("no-announce") => {
362                    announce = false;
363                }
364                Long("quiet") | Short('q') => {
365                    quiet = true;
366                }
367                Long("repo") => {
368                    let val = parser.value()?;
369                    let rid = term::args::rid(&val)?;
370
371                    repo = Some(rid);
372                }
373
374                Value(val) if op.is_none() => match val.to_string_lossy().as_ref() {
375                    "c" | "comment" => op = Some(OperationName::Comment),
376                    "w" | "show" => op = Some(OperationName::Show),
377                    "d" | "delete" => op = Some(OperationName::Delete),
378                    "e" | "edit" => op = Some(OperationName::Edit),
379                    "l" | "list" => op = Some(OperationName::List),
380                    "o" | "open" => op = Some(OperationName::Open),
381                    "r" | "react" => op = Some(OperationName::React),
382                    "s" | "state" => op = Some(OperationName::State),
383                    "assign" => op = Some(OperationName::Assign),
384                    "label" => op = Some(OperationName::Label),
385                    "cache" => op = Some(OperationName::Cache),
386
387                    unknown => anyhow::bail!("unknown operation '{}'", unknown),
388                },
389                Value(val) if op.is_some() => {
390                    let val = term::args::rev(&val)?;
391                    id = Some(val);
392                }
393                _ => {
394                    return Err(anyhow!(arg.unexpected()));
395                }
396            }
397        }
398
399        let op = match op.unwrap_or_default() {
400            OperationName::Edit => Operation::Edit {
401                id: id.ok_or_else(|| anyhow!("an issue must be provided"))?,
402                title,
403                description,
404            },
405            OperationName::Open => Operation::Open {
406                title,
407                description,
408                labels,
409                assignees,
410            },
411            OperationName::Comment => match (reply_to, edit_comment) {
412                (None, None) => Operation::Comment {
413                    id: id.ok_or_else(|| anyhow!("an issue must be provided"))?,
414                    message,
415                    reply_to: None,
416                },
417                (None, Some(comment_id)) => Operation::CommentEdit {
418                    id: id.ok_or_else(|| anyhow!("an issue must be provided"))?,
419                    comment_id,
420                    message,
421                },
422                (reply_to @ Some(_), None) => Operation::Comment {
423                    id: id.ok_or_else(|| anyhow!("an issue must be provided"))?,
424                    message,
425                    reply_to,
426                },
427                (Some(_), Some(_)) => anyhow::bail!("you cannot use --reply-to with --edit"),
428            },
429            OperationName::Show => Operation::Show {
430                id: id.ok_or_else(|| anyhow!("an issue must be provided"))?,
431                format,
432                debug,
433            },
434            OperationName::State => Operation::State {
435                id: id.ok_or_else(|| anyhow!("an issue must be provided"))?,
436                state: state.ok_or_else(|| anyhow!("a state operation must be provided"))?,
437            },
438            OperationName::React => Operation::React {
439                id: id.ok_or_else(|| anyhow!("an issue must be provided"))?,
440                reaction,
441                comment_id,
442            },
443            OperationName::Delete => Operation::Delete {
444                id: id.ok_or_else(|| anyhow!("an issue to remove must be provided"))?,
445            },
446            OperationName::Assign => Operation::Assign {
447                id: id.ok_or_else(|| anyhow!("an issue to label must be provided"))?,
448                opts: assign_opts,
449            },
450            OperationName::Label => Operation::Label {
451                id: id.ok_or_else(|| anyhow!("an issue to label must be provided"))?,
452                opts: label_opts,
453            },
454            OperationName::List => Operation::List { assigned, state },
455            OperationName::Cache => Operation::Cache {
456                id,
457                storage: cache_storage,
458            },
459        };
460
461        Ok((
462            Options {
463                op,
464                repo,
465                announce,
466                quiet,
467            },
468            vec![],
469        ))
470    }
471}
472
473pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
474    let profile = ctx.profile()?;
475    let rid = if let Some(rid) = options.repo {
476        rid
477    } else {
478        radicle::rad::cwd().map(|(_, rid)| rid)?
479    };
480    let repo = profile.storage.repository_mut(rid)?;
481    let announce = options.announce
482        && matches!(
483            &options.op,
484            Operation::Open { .. }
485                | Operation::React { .. }
486                | Operation::State { .. }
487                | Operation::Delete { .. }
488                | Operation::Assign { .. }
489                | Operation::Label { .. }
490                | Operation::Edit { .. }
491                | Operation::Comment { .. }
492        );
493    let mut issues = term::cob::issues_mut(&profile, &repo)?;
494
495    match options.op {
496        Operation::Edit {
497            id,
498            title,
499            description,
500        } => {
501            let signer = term::signer(&profile)?;
502            let issue = edit(&mut issues, &repo, id, title, description, &signer)?;
503            if !options.quiet {
504                term::issue::show(&issue, issue.id(), Format::Header, &profile)?;
505            }
506        }
507        Operation::Open {
508            title: Some(title),
509            description: Some(description),
510            labels,
511            assignees,
512        } => {
513            let signer = term::signer(&profile)?;
514            let issue = issues.create(title, description, &labels, &assignees, [], &signer)?;
515            if !options.quiet {
516                term::issue::show(&issue, issue.id(), Format::Header, &profile)?;
517            }
518        }
519        Operation::Comment {
520            id,
521            message,
522            reply_to,
523        } => {
524            let signer = term::signer(&profile)?;
525            let issue_id = id.resolve::<cob::ObjectId>(&repo.backend)?;
526            let mut issue = issues.get_mut(&issue_id)?;
527            let (body, reply_to) = prompt_comment(message, reply_to, &issue, &repo)?;
528            let comment_id = issue.comment(body, reply_to, vec![], &signer)?;
529
530            if options.quiet {
531                term::print(comment_id);
532            } else {
533                let comment = issue.thread().comment(&comment_id).unwrap();
534                term::comment::widget(&comment_id, comment, &profile).print();
535            }
536        }
537        Operation::CommentEdit {
538            id,
539            comment_id,
540            message,
541        } => {
542            let signer = term::signer(&profile)?;
543            let issue_id = id.resolve::<cob::ObjectId>(&repo.backend)?;
544            let comment_id = comment_id.resolve(&repo.backend)?;
545            let mut issue = issues.get_mut(&issue_id)?;
546            let (body, _) = prompt_comment(message, None, &issue, &repo)?;
547            issue.edit_comment(comment_id, body, vec![], &signer)?;
548
549            if options.quiet {
550                term::print(comment_id);
551            } else {
552                let comment = issue.thread().comment(&comment_id).unwrap();
553                term::comment::widget(&comment_id, comment, &profile).print();
554            }
555        }
556        Operation::Show { id, format, debug } => {
557            let id = id.resolve(&repo.backend)?;
558            let issue = issues
559                .get(&id)
560                .map_err(|e| Error::WithHint {
561                    err: e.into(),
562                    hint: "reset the cache with `rad issue cache` and try again",
563                })?
564                .context("No issue with the given ID exists")?;
565            if debug {
566                println!("{:#?}", issue);
567            } else {
568                term::issue::show(&issue, &id, format, &profile)?;
569            }
570        }
571        Operation::State { id, state } => {
572            let signer = term::signer(&profile)?;
573            let id = id.resolve(&repo.backend)?;
574            let mut issue = issues.get_mut(&id)?;
575            issue.lifecycle(state, &signer)?;
576            if !options.quiet {
577                let success =
578                    |status| term::success!("Issue {} is now {status}", term::format::cob(&id));
579                match state {
580                    State::Closed { reason } => match reason {
581                        CloseReason::Other => success("closed"),
582                        CloseReason::Solved => success("solved"),
583                    },
584                    State::Open => success("open"),
585                };
586            }
587        }
588        Operation::React {
589            id,
590            reaction,
591            comment_id,
592        } => {
593            let id = id.resolve(&repo.backend)?;
594            if let Ok(mut issue) = issues.get_mut(&id) {
595                let signer = term::signer(&profile)?;
596                let comment_id = match comment_id {
597                    Some(cid) => cid,
598                    None => *term::io::comment_select(&issue).map(|(cid, _)| cid)?,
599                };
600                let reaction = match reaction {
601                    Some(reaction) => reaction,
602                    None => term::io::reaction_select()?,
603                };
604                // SAFETY: reaction is never None here.
605                issue.react(comment_id, reaction, true, &signer)?;
606            }
607        }
608        Operation::Open {
609            ref title,
610            ref description,
611            ref labels,
612            ref assignees,
613        } => {
614            let signer = term::signer(&profile)?;
615            open(
616                title.clone(),
617                description.clone(),
618                labels.to_vec(),
619                assignees.to_vec(),
620                &options,
621                &mut issues,
622                &signer,
623                &profile,
624            )?;
625        }
626        Operation::Assign {
627            id,
628            opts: AssignOptions { add, delete },
629        } => {
630            let signer = term::signer(&profile)?;
631            let id = id.resolve(&repo.backend)?;
632            let Ok(mut issue) = issues.get_mut(&id) else {
633                anyhow::bail!("Issue `{id}` not found");
634            };
635            let assignees = issue
636                .assignees()
637                .filter(|did| !delete.contains(did))
638                .chain(add.iter())
639                .cloned()
640                .collect::<Vec<_>>();
641            issue.assign(assignees, &signer)?;
642        }
643        Operation::Label {
644            id,
645            opts: LabelOptions { add, delete },
646        } => {
647            let signer = term::signer(&profile)?;
648            let id = id.resolve(&repo.backend)?;
649            let Ok(mut issue) = issues.get_mut(&id) else {
650                anyhow::bail!("Issue `{id}` not found");
651            };
652            let labels = issue
653                .labels()
654                .filter(|did| !delete.contains(did))
655                .chain(add.iter())
656                .cloned()
657                .collect::<Vec<_>>();
658            issue.label(labels, &signer)?;
659        }
660        Operation::List { assigned, state } => {
661            list(issues, &assigned, &state, &profile)?;
662        }
663        Operation::Delete { id } => {
664            let signer = term::signer(&profile)?;
665            let id = id.resolve(&repo.backend)?;
666            issues.remove(&id, &signer)?;
667        }
668        Operation::Cache { id, storage } => {
669            let mode = if storage {
670                cache::CacheMode::Storage
671            } else {
672                let issue_id = id.map(|id| id.resolve(&repo.backend)).transpose()?;
673                issue_id.map_or(cache::CacheMode::Repository { repository: &repo }, |id| {
674                    cache::CacheMode::Issue {
675                        id,
676                        repository: &repo,
677                    }
678                })
679            };
680            cache::run(mode, &profile)?;
681        }
682    }
683
684    if announce {
685        let mut node = Node::new(profile.socket());
686        node::announce(
687            &repo,
688            node::SyncSettings::default(),
689            node::SyncReporting::default(),
690            &mut node,
691            &profile,
692        )?;
693    }
694
695    Ok(())
696}
697
698fn list<C>(
699    cache: C,
700    assigned: &Option<Assigned>,
701    state: &Option<State>,
702    profile: &profile::Profile,
703) -> anyhow::Result<()>
704where
705    C: issue::cache::Issues,
706{
707    if cache.is_empty()? {
708        term::print(term::format::italic("Nothing to show."));
709        return Ok(());
710    }
711
712    let assignee = match assigned {
713        Some(Assigned::Me) => Some(*profile.id()),
714        Some(Assigned::Peer(id)) => Some((*id).into()),
715        None => None,
716    };
717
718    let mut all = Vec::new();
719    let issues = cache.list()?;
720    for result in issues {
721        let (id, issue) = match result {
722            Ok((id, issue)) => (id, issue),
723            Err(e) => {
724                // Skip issues that failed to load.
725                log::error!(target: "cli", "Issue load error: {e}");
726                continue;
727            }
728        };
729
730        if let Some(a) = assignee {
731            if !issue.assignees().any(|v| v == &Did::from(a)) {
732                continue;
733            }
734        }
735        if let Some(s) = state {
736            if s != issue.state() {
737                continue;
738            }
739        }
740        all.push((id, issue))
741    }
742
743    all.sort_by(|(id1, i1), (id2, i2)| {
744        let by_timestamp = i2.timestamp().cmp(&i1.timestamp());
745        let by_id = id1.cmp(id2);
746
747        by_timestamp.then(by_id)
748    });
749
750    let mut table = term::Table::new(term::table::TableOptions::bordered());
751    table.header([
752        term::format::dim(String::from("●")).into(),
753        term::format::bold(String::from("ID")).into(),
754        term::format::bold(String::from("Title")).into(),
755        term::format::bold(String::from("Author")).into(),
756        term::Line::blank(),
757        term::format::bold(String::from("Labels")).into(),
758        term::format::bold(String::from("Assignees")).into(),
759        term::format::bold(String::from("Opened")).into(),
760    ]);
761    table.divider();
762
763    for (id, issue) in all {
764        let assigned: String = issue
765            .assignees()
766            .map(|did| {
767                let (alias, _) = Author::new(did.as_key(), profile).labels();
768
769                alias.content().to_owned()
770            })
771            .collect::<Vec<_>>()
772            .join(", ");
773
774        let mut labels = issue.labels().map(|t| t.to_string()).collect::<Vec<_>>();
775        labels.sort();
776
777        let author = issue.author().id;
778        let (alias, did) = Author::new(&author, profile).labels();
779
780        table.push([
781            match issue.state() {
782                State::Open => term::format::positive("●").into(),
783                State::Closed { .. } => term::format::negative("●").into(),
784            },
785            term::format::tertiary(term::format::cob(&id))
786                .to_owned()
787                .into(),
788            term::format::default(issue.title().to_owned()).into(),
789            alias.into(),
790            did.into(),
791            term::format::secondary(labels.join(", ")).into(),
792            if assigned.is_empty() {
793                term::format::dim(String::default()).into()
794            } else {
795                term::format::primary(assigned.to_string()).dim().into()
796            },
797            term::format::timestamp(issue.timestamp())
798                .dim()
799                .italic()
800                .into(),
801        ]);
802    }
803    table.print();
804
805    Ok(())
806}
807
808fn open<R, G>(
809    title: Option<String>,
810    description: Option<String>,
811    labels: Vec<Label>,
812    assignees: Vec<Did>,
813    options: &Options,
814    cache: &mut issue::Cache<issue::Issues<'_, R>, cob::cache::StoreWriter>,
815    signer: &Device<G>,
816    profile: &Profile,
817) -> anyhow::Result<()>
818where
819    R: ReadRepository + WriteRepository + cob::Store<Namespace = NodeId>,
820    G: crypto::signature::Signer<crypto::Signature>,
821{
822    let (title, description) = if let (Some(t), Some(d)) = (title.as_ref(), description.as_ref()) {
823        (t.to_owned(), d.to_owned())
824    } else if let Some((t, d)) = term::issue::get_title_description(title, description)? {
825        (t, d)
826    } else {
827        anyhow::bail!("aborting issue creation due to empty title or description");
828    };
829    let issue = cache.create(
830        &title,
831        description,
832        labels.as_slice(),
833        assignees.as_slice(),
834        [],
835        signer,
836    )?;
837
838    if !options.quiet {
839        term::issue::show(&issue, issue.id(), Format::Header, profile)?;
840    }
841    Ok(())
842}
843
844fn edit<'a, 'g, R, G>(
845    issues: &'g mut issue::Cache<issue::Issues<'a, R>, cob::cache::StoreWriter>,
846    repo: &storage::git::Repository,
847    id: Rev,
848    title: Option<String>,
849    description: Option<String>,
850    signer: &Device<G>,
851) -> anyhow::Result<issue::IssueMut<'a, 'g, R, cob::cache::StoreWriter>>
852where
853    R: WriteRepository + ReadRepository + cob::Store<Namespace = NodeId>,
854    G: crypto::signature::Signer<crypto::Signature>,
855{
856    let id = id.resolve(&repo.backend)?;
857    let mut issue = issues.get_mut(&id)?;
858    let (root, _) = issue.root();
859    let comment_id = *root;
860
861    if title.is_some() || description.is_some() {
862        // Editing by command line arguments.
863        issue.transaction("Edit", signer, |tx| {
864            if let Some(t) = title {
865                tx.edit(t)?;
866            }
867            if let Some(d) = description {
868                tx.edit_comment(comment_id, d, vec![])?;
869            }
870            Ok(())
871        })?;
872        return Ok(issue);
873    }
874
875    // Editing via the editor.
876    let Some((title, description)) = term::issue::get_title_description(
877        Some(title.unwrap_or(issue.title().to_owned())),
878        Some(description.unwrap_or(issue.description().to_owned())),
879    )?
880    else {
881        return Ok(issue);
882    };
883
884    issue.transaction("Edit", signer, |tx| {
885        tx.edit(title)?;
886        tx.edit_comment(comment_id, description, vec![])?;
887
888        Ok(())
889    })?;
890
891    Ok(issue)
892}
893
894/// Get a comment from the user, by prompting.
895pub fn prompt_comment<R: WriteRepository + radicle::cob::Store<Namespace = NodeId>>(
896    message: Message,
897    reply_to: Option<Rev>,
898    issue: &issue::Issue,
899    repo: &R,
900) -> anyhow::Result<(String, thread::CommentId)> {
901    let (root, r) = issue.root();
902    let (reply_to, help) = if let Some(rev) = reply_to {
903        let id = rev.resolve::<radicle::git::Oid>(repo.raw())?;
904        let parent = issue
905            .thread()
906            .comment(&id)
907            .ok_or(anyhow::anyhow!("comment '{rev}' not found"))?;
908
909        (id, parent.body().trim())
910    } else {
911        (*root, r.body().trim())
912    };
913    let help = format!("\n{}\n", term::format::html::commented(help));
914    let body = message.get(&help)?;
915
916    if body.is_empty() {
917        anyhow::bail!("aborting operation due to empty comment");
918    }
919    Ok((body, reply_to))
920}