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::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        --debug                Show the issue as Rust debug output
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<String>,
111        description: Option<String>,
112    },
113    Open {
114        title: Option<String>,
115        description: Option<String>,
116        labels: Vec<Label>,
117        assignees: Vec<Did>,
118    },
119    Show {
120        id: Rev,
121        format: Format,
122        debug: 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<String> = 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 debug = 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                    title = Some(parser.value()?.to_string_lossy().into());
240                }
241                Long("description")
242                    if op == Some(OperationName::Open) || op == Some(OperationName::Edit) =>
243                {
244                    description = Some(parser.value()?.to_string_lossy().into());
245                }
246                Short('l') | Long("label") if matches!(op, Some(OperationName::Open)) => {
247                    let val = parser.value()?;
248                    let name = term::args::string(&val);
249                    let label = Label::new(name)?;
250
251                    labels.push(label);
252                }
253                Long("assign") if op == Some(OperationName::Open) => {
254                    let val = parser.value()?;
255                    let did = term::args::did(&val)?;
256
257                    assignees.push(did);
258                }
259
260                // State options.
261                Long("closed") if op == Some(OperationName::State) => {
262                    state = Some(State::Closed {
263                        reason: CloseReason::Other,
264                    });
265                }
266                Long("open") if op == Some(OperationName::State) => {
267                    state = Some(State::Open);
268                }
269                Long("solved") if op == Some(OperationName::State) => {
270                    state = Some(State::Closed {
271                        reason: CloseReason::Solved,
272                    });
273                }
274
275                // React options.
276                Long("emoji") if op == Some(OperationName::React) => {
277                    if let Some(emoji) = parser.value()?.to_str() {
278                        reaction =
279                            Some(Reaction::from_str(emoji).map_err(|_| anyhow!("invalid emoji"))?);
280                    }
281                }
282                Long("to") if op == Some(OperationName::React) => {
283                    let oid: String = parser.value()?.to_string_lossy().into();
284                    comment_id = Some(oid.parse()?);
285                }
286
287                // Show options.
288                Long("format") if op == Some(OperationName::Show) => {
289                    let val = parser.value()?;
290                    let val = term::args::string(&val);
291
292                    match val.as_str() {
293                        "header" => format = Format::Header,
294                        "full" => format = Format::Full,
295                        _ => anyhow::bail!("unknown format '{val}'"),
296                    }
297                }
298                Long("debug") if op == Some(OperationName::Show) => {
299                    debug = true;
300                }
301
302                // Comment options.
303                Long("message") | Short('m') if op == Some(OperationName::Comment) => {
304                    let val = parser.value()?;
305                    let txt = term::args::string(&val);
306
307                    message.append(&txt);
308                }
309                Long("reply-to") if op == Some(OperationName::Comment) => {
310                    let val = parser.value()?;
311                    let rev = term::args::rev(&val)?;
312
313                    reply_to = Some(rev);
314                }
315                Long("edit") if op == Some(OperationName::Comment) => {
316                    let val = parser.value()?;
317                    let rev = term::args::rev(&val)?;
318
319                    edit_comment = Some(rev);
320                }
321
322                // Assign options
323                Short('a') | Long("add") if op == Some(OperationName::Assign) => {
324                    assign_opts.add.insert(term::args::did(&parser.value()?)?);
325                }
326                Short('d') | Long("delete") if op == Some(OperationName::Assign) => {
327                    assign_opts
328                        .delete
329                        .insert(term::args::did(&parser.value()?)?);
330                }
331                Long("assigned") | Short('a') if assigned.is_none() => {
332                    if let Ok(val) = parser.value() {
333                        let peer = term::args::did(&val)?;
334                        assigned = Some(Assigned::Peer(peer));
335                    } else {
336                        assigned = Some(Assigned::Me);
337                    }
338                }
339
340                // Label options
341                Short('a') | Long("add") if matches!(op, Some(OperationName::Label)) => {
342                    let val = parser.value()?;
343                    let name = term::args::string(&val);
344                    let label = Label::new(name)?;
345
346                    label_opts.add.insert(label);
347                }
348                Short('d') | Long("delete") if matches!(op, Some(OperationName::Label)) => {
349                    let val = parser.value()?;
350                    let name = term::args::string(&val);
351                    let label = Label::new(name)?;
352
353                    label_opts.delete.insert(label);
354                }
355
356                // Cache options.
357                Long("storage") if matches!(op, Some(OperationName::Cache)) => {
358                    cache_storage = true;
359                }
360
361                // Options.
362                Long("no-announce") => {
363                    announce = false;
364                }
365                Long("quiet") | Short('q') => {
366                    quiet = true;
367                }
368                Long("repo") => {
369                    let val = parser.value()?;
370                    let rid = term::args::rid(&val)?;
371
372                    repo = Some(rid);
373                }
374
375                Value(val) if op.is_none() => match val.to_string_lossy().as_ref() {
376                    "c" | "comment" => op = Some(OperationName::Comment),
377                    "w" | "show" => op = Some(OperationName::Show),
378                    "d" | "delete" => op = Some(OperationName::Delete),
379                    "e" | "edit" => op = Some(OperationName::Edit),
380                    "l" | "list" => op = Some(OperationName::List),
381                    "o" | "open" => op = Some(OperationName::Open),
382                    "r" | "react" => op = Some(OperationName::React),
383                    "s" | "state" => op = Some(OperationName::State),
384                    "assign" => op = Some(OperationName::Assign),
385                    "label" => op = Some(OperationName::Label),
386                    "cache" => op = Some(OperationName::Cache),
387
388                    unknown => anyhow::bail!("unknown operation '{}'", unknown),
389                },
390                Value(val) if op.is_some() => {
391                    let val = term::args::rev(&val)?;
392                    id = Some(val);
393                }
394                _ => {
395                    return Err(anyhow!(arg.unexpected()));
396                }
397            }
398        }
399
400        let op = match op.unwrap_or_default() {
401            OperationName::Edit => Operation::Edit {
402                id: id.ok_or_else(|| anyhow!("an issue must be provided"))?,
403                title,
404                description,
405            },
406            OperationName::Open => Operation::Open {
407                title,
408                description,
409                labels,
410                assignees,
411            },
412            OperationName::Comment => match (reply_to, edit_comment) {
413                (None, None) => Operation::Comment {
414                    id: id.ok_or_else(|| anyhow!("an issue must be provided"))?,
415                    message,
416                    reply_to: None,
417                },
418                (None, Some(comment_id)) => Operation::CommentEdit {
419                    id: id.ok_or_else(|| anyhow!("an issue must be provided"))?,
420                    comment_id,
421                    message,
422                },
423                (reply_to @ Some(_), None) => Operation::Comment {
424                    id: id.ok_or_else(|| anyhow!("an issue must be provided"))?,
425                    message,
426                    reply_to,
427                },
428                (Some(_), Some(_)) => anyhow::bail!("you cannot use --reply-to with --edit"),
429            },
430            OperationName::Show => Operation::Show {
431                id: id.ok_or_else(|| anyhow!("an issue must be provided"))?,
432                format,
433                debug,
434            },
435            OperationName::State => Operation::State {
436                id: id.ok_or_else(|| anyhow!("an issue must be provided"))?,
437                state: state.ok_or_else(|| anyhow!("a state operation must be provided"))?,
438            },
439            OperationName::React => Operation::React {
440                id: id.ok_or_else(|| anyhow!("an issue must be provided"))?,
441                reaction,
442                comment_id,
443            },
444            OperationName::Delete => Operation::Delete {
445                id: id.ok_or_else(|| anyhow!("an issue to remove must be provided"))?,
446            },
447            OperationName::Assign => Operation::Assign {
448                id: id.ok_or_else(|| anyhow!("an issue to label must be provided"))?,
449                opts: assign_opts,
450            },
451            OperationName::Label => Operation::Label {
452                id: id.ok_or_else(|| anyhow!("an issue to label must be provided"))?,
453                opts: label_opts,
454            },
455            OperationName::List => Operation::List { assigned, state },
456            OperationName::Cache => Operation::Cache {
457                id,
458                storage: cache_storage,
459            },
460        };
461
462        Ok((
463            Options {
464                op,
465                repo,
466                announce,
467                quiet,
468            },
469            vec![],
470        ))
471    }
472}
473
474pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
475    let profile = ctx.profile()?;
476    let rid = if let Some(rid) = options.repo {
477        rid
478    } else {
479        radicle::rad::cwd().map(|(_, rid)| rid)?
480    };
481    let repo = profile.storage.repository_mut(rid)?;
482    let announce = options.announce
483        && matches!(
484            &options.op,
485            Operation::Open { .. }
486                | Operation::React { .. }
487                | Operation::State { .. }
488                | Operation::Delete { .. }
489                | Operation::Assign { .. }
490                | Operation::Label { .. }
491                | Operation::Edit { .. }
492                | Operation::Comment { .. }
493        );
494    let mut issues = term::cob::issues_mut(&profile, &repo)?;
495
496    match options.op {
497        Operation::Edit {
498            id,
499            title,
500            description,
501        } => {
502            let signer = term::signer(&profile)?;
503            let issue = edit(&mut issues, &repo, id, title, description, &signer)?;
504            if !options.quiet {
505                term::issue::show(&issue, issue.id(), Format::Header, &profile)?;
506            }
507        }
508        Operation::Open {
509            title: Some(title),
510            description: Some(description),
511            labels,
512            assignees,
513        } => {
514            let signer = term::signer(&profile)?;
515            let issue = issues.create(title, description, &labels, &assignees, [], &signer)?;
516            if !options.quiet {
517                term::issue::show(&issue, issue.id(), Format::Header, &profile)?;
518            }
519        }
520        Operation::Comment {
521            id,
522            message,
523            reply_to,
524        } => {
525            let reply_to = reply_to
526                .map(|rev| rev.resolve::<radicle::git::Oid>(repo.raw()))
527                .transpose()?;
528
529            let signer = term::signer(&profile)?;
530            let issue_id = id.resolve::<cob::ObjectId>(&repo.backend)?;
531            let mut issue = issues.get_mut(&issue_id)?;
532
533            let (root_comment_id, _) = issue.root();
534            let body = prompt_comment(message, issue.thread(), reply_to, None)?;
535            let comment_id =
536                issue.comment(body, reply_to.unwrap_or(*root_comment_id), vec![], &signer)?;
537
538            if options.quiet {
539                term::print(comment_id);
540            } else {
541                let comment = issue.thread().comment(&comment_id).unwrap();
542                term::comment::widget(&comment_id, comment, &profile).print();
543            }
544        }
545        Operation::CommentEdit {
546            id,
547            comment_id,
548            message,
549        } => {
550            let signer = term::signer(&profile)?;
551            let issue_id = id.resolve::<cob::ObjectId>(&repo.backend)?;
552            let comment_id = comment_id.resolve(&repo.backend)?;
553            let mut issue = issues.get_mut(&issue_id)?;
554
555            let comment = issue
556                .thread()
557                .comment(&comment_id)
558                .ok_or(anyhow::anyhow!("comment '{comment_id}' not found"))?;
559
560            let body = prompt_comment(
561                message,
562                issue.thread(),
563                comment.reply_to(),
564                Some(comment.body()),
565            )?;
566            issue.edit_comment(comment_id, body, vec![], &signer)?;
567
568            if options.quiet {
569                term::print(comment_id);
570            } else {
571                let comment = issue.thread().comment(&comment_id).unwrap();
572                term::comment::widget(&comment_id, comment, &profile).print();
573            }
574        }
575        Operation::Show { id, format, debug } => {
576            let id = id.resolve(&repo.backend)?;
577            let issue = issues
578                .get(&id)
579                .map_err(|e| Error::WithHint {
580                    err: e.into(),
581                    hint: "reset the cache with `rad issue cache` and try again",
582                })?
583                .context("No issue with the given ID exists")?;
584            if debug {
585                println!("{:#?}", issue);
586            } else {
587                term::issue::show(&issue, &id, format, &profile)?;
588            }
589        }
590        Operation::State { id, state } => {
591            let signer = term::signer(&profile)?;
592            let id = id.resolve(&repo.backend)?;
593            let mut issue = issues.get_mut(&id)?;
594            issue.lifecycle(state, &signer)?;
595            if !options.quiet {
596                let success =
597                    |status| term::success!("Issue {} is now {status}", term::format::cob(&id));
598                match state {
599                    State::Closed { reason } => match reason {
600                        CloseReason::Other => success("closed"),
601                        CloseReason::Solved => success("solved"),
602                    },
603                    State::Open => success("open"),
604                };
605            }
606        }
607        Operation::React {
608            id,
609            reaction,
610            comment_id,
611        } => {
612            let id = id.resolve(&repo.backend)?;
613            if let Ok(mut issue) = issues.get_mut(&id) {
614                let signer = term::signer(&profile)?;
615                let comment_id = match comment_id {
616                    Some(cid) => cid,
617                    None => *term::io::comment_select(&issue).map(|(cid, _)| cid)?,
618                };
619                let reaction = match reaction {
620                    Some(reaction) => reaction,
621                    None => term::io::reaction_select()?,
622                };
623                // SAFETY: reaction is never None here.
624                issue.react(comment_id, reaction, true, &signer)?;
625            }
626        }
627        Operation::Open {
628            ref title,
629            ref description,
630            ref labels,
631            ref assignees,
632        } => {
633            let signer = term::signer(&profile)?;
634            open(
635                title.clone(),
636                description.clone(),
637                labels.to_vec(),
638                assignees.to_vec(),
639                &options,
640                &mut issues,
641                &signer,
642                &profile,
643            )?;
644        }
645        Operation::Assign {
646            id,
647            opts: AssignOptions { add, delete },
648        } => {
649            let signer = term::signer(&profile)?;
650            let id = id.resolve(&repo.backend)?;
651            let Ok(mut issue) = issues.get_mut(&id) else {
652                anyhow::bail!("Issue `{id}` not found");
653            };
654            let assignees = issue
655                .assignees()
656                .filter(|did| !delete.contains(did))
657                .chain(add.iter())
658                .cloned()
659                .collect::<Vec<_>>();
660            issue.assign(assignees, &signer)?;
661        }
662        Operation::Label {
663            id,
664            opts: LabelOptions { add, delete },
665        } => {
666            let signer = term::signer(&profile)?;
667            let id = id.resolve(&repo.backend)?;
668            let Ok(mut issue) = issues.get_mut(&id) else {
669                anyhow::bail!("Issue `{id}` not found");
670            };
671            let labels = issue
672                .labels()
673                .filter(|did| !delete.contains(did))
674                .chain(add.iter())
675                .cloned()
676                .collect::<Vec<_>>();
677            issue.label(labels, &signer)?;
678        }
679        Operation::List { assigned, state } => {
680            list(issues, &assigned, &state, &profile)?;
681        }
682        Operation::Delete { id } => {
683            let signer = term::signer(&profile)?;
684            let id = id.resolve(&repo.backend)?;
685            issues.remove(&id, &signer)?;
686        }
687        Operation::Cache { id, storage } => {
688            let mode = if storage {
689                cache::CacheMode::Storage
690            } else {
691                let issue_id = id.map(|id| id.resolve(&repo.backend)).transpose()?;
692                issue_id.map_or(cache::CacheMode::Repository { repository: &repo }, |id| {
693                    cache::CacheMode::Issue {
694                        id,
695                        repository: &repo,
696                    }
697                })
698            };
699            cache::run(mode, &profile)?;
700        }
701    }
702
703    if announce {
704        let mut node = Node::new(profile.socket());
705        node::announce(
706            &repo,
707            node::SyncSettings::default(),
708            node::SyncReporting::default(),
709            &mut node,
710            &profile,
711        )?;
712    }
713
714    Ok(())
715}
716
717fn list<C>(
718    cache: C,
719    assigned: &Option<Assigned>,
720    state: &Option<State>,
721    profile: &profile::Profile,
722) -> anyhow::Result<()>
723where
724    C: issue::cache::Issues,
725{
726    if cache.is_empty()? {
727        term::print(term::format::italic("Nothing to show."));
728        return Ok(());
729    }
730
731    let assignee = match assigned {
732        Some(Assigned::Me) => Some(*profile.id()),
733        Some(Assigned::Peer(id)) => Some((*id).into()),
734        None => None,
735    };
736
737    let mut all = Vec::new();
738    let issues = cache.list()?;
739    for result in issues {
740        let (id, issue) = match result {
741            Ok((id, issue)) => (id, issue),
742            Err(e) => {
743                // Skip issues that failed to load.
744                log::error!(target: "cli", "Issue load error: {e}");
745                continue;
746            }
747        };
748
749        if let Some(a) = assignee {
750            if !issue.assignees().any(|v| v == &Did::from(a)) {
751                continue;
752            }
753        }
754        if let Some(s) = state {
755            if s != issue.state() {
756                continue;
757            }
758        }
759        all.push((id, issue))
760    }
761
762    all.sort_by(|(id1, i1), (id2, i2)| {
763        let by_timestamp = i2.timestamp().cmp(&i1.timestamp());
764        let by_id = id1.cmp(id2);
765
766        by_timestamp.then(by_id)
767    });
768
769    let mut table = term::Table::new(term::table::TableOptions::bordered());
770    table.header([
771        term::format::dim(String::from("●")).into(),
772        term::format::bold(String::from("ID")).into(),
773        term::format::bold(String::from("Title")).into(),
774        term::format::bold(String::from("Author")).into(),
775        term::Line::blank(),
776        term::format::bold(String::from("Labels")).into(),
777        term::format::bold(String::from("Assignees")).into(),
778        term::format::bold(String::from("Opened")).into(),
779    ]);
780    table.divider();
781
782    for (id, issue) in all {
783        let assigned: String = issue
784            .assignees()
785            .map(|did| {
786                let (alias, _) = Author::new(did.as_key(), profile).labels();
787
788                alias.content().to_owned()
789            })
790            .collect::<Vec<_>>()
791            .join(", ");
792
793        let mut labels = issue.labels().map(|t| t.to_string()).collect::<Vec<_>>();
794        labels.sort();
795
796        let author = issue.author().id;
797        let (alias, did) = Author::new(&author, profile).labels();
798
799        table.push([
800            match issue.state() {
801                State::Open => term::format::positive("●").into(),
802                State::Closed { .. } => term::format::negative("●").into(),
803            },
804            term::format::tertiary(term::format::cob(&id))
805                .to_owned()
806                .into(),
807            term::format::default(issue.title().to_owned()).into(),
808            alias.into(),
809            did.into(),
810            term::format::secondary(labels.join(", ")).into(),
811            if assigned.is_empty() {
812                term::format::dim(String::default()).into()
813            } else {
814                term::format::primary(assigned.to_string()).dim().into()
815            },
816            term::format::timestamp(issue.timestamp())
817                .dim()
818                .italic()
819                .into(),
820        ]);
821    }
822    table.print();
823
824    Ok(())
825}
826
827fn open<R, G>(
828    title: Option<String>,
829    description: Option<String>,
830    labels: Vec<Label>,
831    assignees: Vec<Did>,
832    options: &Options,
833    cache: &mut issue::Cache<issue::Issues<'_, R>, cob::cache::StoreWriter>,
834    signer: &Device<G>,
835    profile: &Profile,
836) -> anyhow::Result<()>
837where
838    R: ReadRepository + WriteRepository + cob::Store<Namespace = NodeId>,
839    G: crypto::signature::Signer<crypto::Signature>,
840{
841    let (title, description) = if let (Some(t), Some(d)) = (title.as_ref(), description.as_ref()) {
842        (t.to_owned(), d.to_owned())
843    } else if let Some((t, d)) = term::issue::get_title_description(title, description)? {
844        (t, d)
845    } else {
846        anyhow::bail!("aborting issue creation due to empty title or description");
847    };
848    let issue = cache.create(
849        &title,
850        description,
851        labels.as_slice(),
852        assignees.as_slice(),
853        [],
854        signer,
855    )?;
856
857    if !options.quiet {
858        term::issue::show(&issue, issue.id(), Format::Header, profile)?;
859    }
860    Ok(())
861}
862
863fn edit<'a, 'g, R, G>(
864    issues: &'g mut issue::Cache<issue::Issues<'a, R>, cob::cache::StoreWriter>,
865    repo: &storage::git::Repository,
866    id: Rev,
867    title: Option<String>,
868    description: Option<String>,
869    signer: &Device<G>,
870) -> anyhow::Result<issue::IssueMut<'a, 'g, R, cob::cache::StoreWriter>>
871where
872    R: WriteRepository + ReadRepository + cob::Store<Namespace = NodeId>,
873    G: crypto::signature::Signer<crypto::Signature>,
874{
875    let id = id.resolve(&repo.backend)?;
876    let mut issue = issues.get_mut(&id)?;
877    let (root, _) = issue.root();
878    let comment_id = *root;
879
880    if title.is_some() || description.is_some() {
881        // Editing by command line arguments.
882        issue.transaction("Edit", signer, |tx| {
883            if let Some(t) = title {
884                tx.edit(t)?;
885            }
886            if let Some(d) = description {
887                tx.edit_comment(comment_id, d, vec![])?;
888            }
889            Ok(())
890        })?;
891        return Ok(issue);
892    }
893
894    // Editing via the editor.
895    let Some((title, description)) = term::issue::get_title_description(
896        Some(title.unwrap_or(issue.title().to_owned())),
897        Some(description.unwrap_or(issue.description().to_owned())),
898    )?
899    else {
900        return Ok(issue);
901    };
902
903    issue.transaction("Edit", signer, |tx| {
904        tx.edit(title)?;
905        tx.edit_comment(comment_id, description, vec![])?;
906
907        Ok(())
908    })?;
909
910    Ok(issue)
911}
912
913/// Get a comment from the user, by prompting.
914pub fn prompt_comment(
915    message: Message,
916    thread: &thread::Thread,
917    mut reply_to: Option<Oid>,
918    edit: Option<&str>,
919) -> anyhow::Result<String> {
920    let (chase, missing) = {
921        let mut chase = Vec::with_capacity(thread.len());
922        let mut missing = None;
923
924        while let Some(id) = reply_to {
925            if let Some(comment) = thread.comment(&id) {
926                chase.push(comment);
927                reply_to = comment.reply_to();
928            } else {
929                missing = reply_to;
930                break;
931            }
932        }
933
934        (chase, missing)
935    };
936
937    let quotes = if chase.is_empty() {
938        ""
939    } else {
940        "Quotes (lines starting with '>') will be preserved. Please remove those that you do not intend to keep.\n"
941    };
942
943    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());
944    buffer.push('\n');
945
946    for comment in chase.iter().rev() {
947        buffer.reserve(2);
948        buffer.push('\n');
949        comment_quoted(comment, &mut buffer);
950    }
951
952    if let Some(id) = missing {
953        buffer.push('\n');
954        buffer.push_str(
955            term::format::html::commented(
956                format!("The comment with ID {id} that was replied to could not be found.")
957                    .as_str(),
958            )
959            .as_str(),
960        );
961    }
962
963    if let Some(edit) = edit {
964        if !chase.is_empty() {
965            buffer.push_str(
966                "\n<!-- The contents of the comment you are editing follow below this line. -->\n",
967            );
968        }
969        buffer.reserve(2 + edit.len());
970        buffer.push('\n');
971        buffer.push_str(edit);
972    }
973
974    let body = message.get(&buffer)?;
975
976    if body.is_empty() {
977        anyhow::bail!("aborting operation due to empty comment");
978    }
979    Ok(body)
980}
981
982fn comment_quoted(comment: &thread::Comment, buffer: &mut String) {
983    let body = comment.body();
984    let lines = body.lines();
985
986    let hint = {
987        let (lower, upper) = lines.size_hint();
988        upper.unwrap_or(lower)
989    };
990
991    buffer.push_str(format!("{} wrote:\n", comment.author()).as_str());
992    buffer.reserve(body.len() + hint * 2);
993
994    for line in lines {
995        buffer.push('>');
996        if !line.is_empty() {
997            buffer.push(' ');
998        }
999        buffer.push_str(line);
1000        buffer.push('\n');
1001    }
1002}