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