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#[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 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 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 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 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 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 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 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 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 Long("storage") if matches!(op, Some(OperationName::Cache)) => {
359 cache_storage = true;
360 }
361
362 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 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 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 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 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
917pub 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}