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