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