1mod args;
2mod cache;
3mod comment;
4
5use anyhow::Context as _;
6
7use radicle::cob::common::Label;
8use radicle::cob::issue::{CloseReason, State};
9use radicle::cob::{issue, Title};
10
11use radicle::crypto;
12use radicle::issue::cache::Issues as _;
13use radicle::node::device::Device;
14use radicle::node::NodeId;
15use radicle::prelude::Did;
16use radicle::profile;
17use radicle::storage;
18use radicle::storage::{WriteRepository, WriteStorage};
19use radicle::Profile;
20use radicle::{cob, Node};
21
22pub use args::Args;
23use args::{Assigned, Command, CommentAction, StateArg};
24
25use crate::git::Rev;
26use crate::node;
27use crate::terminal as term;
28use crate::terminal::args::Error;
29use crate::terminal::format::Author;
30use crate::terminal::issue::Format;
31use crate::terminal::Element;
32
33pub(crate) const ABOUT: &str = "Manage issues";
34
35pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
36 let profile = ctx.profile()?;
37 let rid = match args.repo {
38 Some(rid) => rid,
39 None => radicle::rad::cwd().map(|(_, rid)| rid)?,
40 };
41
42 let repo = profile.storage.repository_mut(rid)?;
43
44 let command = args
47 .command
48 .unwrap_or_else(|| Command::List(args.empty.into()));
49
50 let announce = !args.no_announce && command.should_announce_for();
51 let mut issues = term::cob::issues_mut(&profile, &repo)?;
52
53 match command {
54 Command::Edit {
55 id,
56 title,
57 description,
58 } => {
59 let signer = term::signer(&profile)?;
60 let issue = edit(&mut issues, &repo, id, title, description, &signer)?;
61 if !args.quiet {
62 term::issue::show(&issue, issue.id(), Format::Header, args.verbose, &profile)?;
63 }
64 }
65 Command::Open {
66 title,
67 description,
68 labels,
69 assignees,
70 } => {
71 let signer = term::signer(&profile)?;
72 open(
73 title,
74 description,
75 labels,
76 assignees,
77 args.verbose,
78 args.quiet,
79 &mut issues,
80 &signer,
81 &profile,
82 )?;
83 }
84 Command::Comment(c) => match CommentAction::from(c) {
85 CommentAction::Comment { id, message } => {
86 comment::comment(&profile, &repo, &mut issues, id, message, None, args.quiet)?;
87 }
88 CommentAction::Reply {
89 id,
90 message,
91 reply_to,
92 } => comment::comment(
93 &profile,
94 &repo,
95 &mut issues,
96 id,
97 message,
98 Some(reply_to),
99 args.quiet,
100 )?,
101 CommentAction::Edit {
102 id,
103 message,
104 to_edit,
105 } => comment::edit(
106 &profile,
107 &repo,
108 &mut issues,
109 id,
110 message,
111 to_edit,
112 args.quiet,
113 )?,
114 },
115 Command::Show { id } => {
116 let format = if args.header {
117 term::issue::Format::Header
118 } else {
119 term::issue::Format::Full
120 };
121
122 let id = id.resolve(&repo.backend)?;
123 let issue = issues
124 .get(&id)
125 .map_err(|e| Error::WithHint {
126 err: e.into(),
127 hint: "reset the cache with `rad issue cache` and try again",
128 })?
129 .context("No issue with the given ID exists")?;
130 term::issue::show(&issue, &id, format, args.verbose, &profile)?;
131 }
132 Command::State { id, target_state } => {
133 let to: StateArg = target_state.into();
134 let id = id.resolve(&repo.backend)?;
135 let signer = term::signer(&profile)?;
136 let mut issue = issues.get_mut(&id)?;
137 let state = to.into();
138 issue.lifecycle(state, &signer)?;
139
140 if !args.quiet {
141 let success =
142 |status| term::success!("Issue {} is now {status}", term::format::cob(&id));
143 match state {
144 State::Closed { reason } => match reason {
145 CloseReason::Other => success("closed"),
146 CloseReason::Solved => success("solved"),
147 },
148 State::Open => success("open"),
149 };
150 }
151 }
152 Command::React {
153 id,
154 reaction,
155 comment_id,
156 } => {
157 let id = id.resolve(&repo.backend)?;
158 if let Ok(mut issue) = issues.get_mut(&id) {
159 let signer = term::signer(&profile)?;
160 let comment_id = match comment_id {
161 Some(cid) => cid.resolve(&repo.backend)?,
162 None => *term::io::comment_select(&issue).map(|(cid, _)| cid)?,
163 };
164 let reaction = match reaction {
165 Some(reaction) => reaction,
166 None => term::io::reaction_select()?,
167 };
168 issue.react(comment_id, reaction, true, &signer)?;
169 }
170 }
171 Command::Assign { id, add, delete } => {
172 let signer = term::signer(&profile)?;
173 let id = id.resolve(&repo.backend)?;
174 let Ok(mut issue) = issues.get_mut(&id) else {
175 anyhow::bail!("Issue `{id}` not found");
176 };
177 let assignees = issue
178 .assignees()
179 .filter(|did| !delete.contains(did))
180 .chain(add.iter())
181 .cloned()
182 .collect::<Vec<_>>();
183 issue.assign(assignees, &signer)?;
184 }
185 Command::Label { id, add, delete } => {
186 let id = id.resolve(&repo.backend)?;
187 let Ok(mut issue) = issues.get_mut(&id) else {
188 anyhow::bail!("Issue `{id}` not found");
189 };
190 let labels = issue
191 .labels()
192 .filter(|did| !delete.contains(did))
193 .chain(add.iter())
194 .cloned()
195 .collect::<Vec<_>>();
196 let signer = term::signer(&profile)?;
197 issue.label(labels, &signer)?;
198 }
199 Command::List(list_args) => {
200 list(
201 issues,
202 &list_args.assigned,
203 &((&list_args.state).into()),
204 &profile,
205 args.verbose,
206 )?;
207 }
208 Command::Delete { id } => {
209 let id = id.resolve(&repo.backend)?;
210 let signer = term::signer(&profile)?;
211 issues.remove(&id, &signer)?;
212 }
213 Command::Cache { id, storage } => {
214 let mode = if storage {
215 cache::CacheMode::Storage
216 } else {
217 let issue_id = id.map(|id| id.resolve(&repo.backend)).transpose()?;
218 issue_id.map_or(cache::CacheMode::Repository { repository: &repo }, |id| {
219 cache::CacheMode::Issue {
220 id,
221 repository: &repo,
222 }
223 })
224 };
225 cache::run(mode, &profile)?;
226 }
227 }
228
229 if announce {
230 let mut node = Node::new(profile.socket());
231 node::announce(
232 &repo,
233 node::SyncSettings::default(),
234 node::SyncReporting::default(),
235 &mut node,
236 &profile,
237 )?;
238 }
239
240 Ok(())
241}
242
243fn list<C>(
244 cache: C,
245 assigned: &Option<Assigned>,
246 state: &Option<State>,
247 profile: &profile::Profile,
248 verbose: bool,
249) -> anyhow::Result<()>
250where
251 C: issue::cache::Issues,
252{
253 if cache.is_empty()? {
254 term::print(term::format::italic("Nothing to show."));
255 return Ok(());
256 }
257
258 let assignee = match assigned {
259 Some(Assigned::Me) => Some(*profile.id()),
260 Some(Assigned::Peer(id)) => Some((*id).into()),
261 None => None,
262 };
263
264 let mut all = cache
265 .list()?
266 .filter_map(|result| {
267 let (id, issue) = match result {
268 Ok((id, issue)) => (id, issue),
269 Err(e) => {
270 log::error!(target: "cli", "Issue load error: {e}");
272 return None;
273 }
274 };
275
276 if let Some(a) = assignee {
277 if !issue.assignees().any(|v| v == &Did::from(a)) {
278 return None;
279 }
280 }
281
282 if let Some(s) = state {
283 if s != issue.state() {
284 return None;
285 }
286 }
287
288 Some((id, issue))
289 })
290 .collect::<Vec<_>>();
291
292 all.sort_by(|(id1, i1), (id2, i2)| {
293 let by_timestamp = i2.timestamp().cmp(&i1.timestamp());
294 let by_id = id1.cmp(id2);
295
296 by_timestamp.then(by_id)
297 });
298
299 let mut table = term::Table::new(term::table::TableOptions::bordered());
300 table.header([
301 term::format::dim(String::from("●")).into(),
302 term::format::bold(String::from("ID")).into(),
303 term::format::bold(String::from("Title")).into(),
304 term::format::bold(String::from("Author")).into(),
305 term::Line::blank(),
306 term::format::bold(String::from("Labels")).into(),
307 term::format::bold(String::from("Assignees")).into(),
308 term::format::bold(String::from("Opened")).into(),
309 ]);
310 table.divider();
311
312 table.extend(all.into_iter().map(|(id, issue)| {
313 let assigned: String = issue
314 .assignees()
315 .map(|did| {
316 let (alias, _) = Author::new(did.as_key(), profile, verbose).labels();
317
318 alias.content().to_owned()
319 })
320 .collect::<Vec<_>>()
321 .join(", ");
322
323 let mut labels = issue.labels().map(|t| t.to_string()).collect::<Vec<_>>();
324 labels.sort();
325
326 let author = issue.author().id;
327 let (alias, did) = Author::new(&author, profile, verbose).labels();
328
329 mk_issue_row(id, issue, assigned, labels, alias, did)
330 }));
331
332 table.print();
333
334 Ok(())
335}
336
337fn mk_issue_row(
338 id: cob::ObjectId,
339 issue: issue::Issue,
340 assigned: String,
341 labels: Vec<String>,
342 alias: radicle_term::Label,
343 did: radicle_term::Label,
344) -> [radicle_term::Line; 8] {
345 [
346 match issue.state() {
347 State::Open => term::format::positive("●").into(),
348 State::Closed { .. } => term::format::negative("●").into(),
349 },
350 term::format::tertiary(term::format::cob(&id))
351 .to_owned()
352 .into(),
353 term::format::default(issue.title().to_owned()).into(),
354 alias.into(),
355 did.into(),
356 term::format::secondary(labels.join(", ")).into(),
357 if assigned.is_empty() {
358 term::format::dim(String::default()).into()
359 } else {
360 term::format::primary(assigned.to_string()).dim().into()
361 },
362 term::format::timestamp(issue.timestamp())
363 .dim()
364 .italic()
365 .into(),
366 ]
367}
368
369fn open<R, G>(
370 title: Option<Title>,
371 description: Option<String>,
372 labels: Vec<Label>,
373 assignees: Vec<Did>,
374 verbose: bool,
375 quiet: bool,
376 cache: &mut issue::Cache<issue::Issues<'_, R>, cob::cache::StoreWriter>,
377 signer: &Device<G>,
378 profile: &Profile,
379) -> anyhow::Result<()>
380where
381 R: WriteRepository + cob::Store<Namespace = NodeId>,
382 G: crypto::signature::Signer<crypto::Signature>,
383{
384 let (title, description) = if let (Some(t), Some(d)) = (title.as_ref(), description.as_ref()) {
385 (t.to_owned(), d.to_owned())
386 } else if let Some((t, d)) = term::issue::get_title_description(title, description)? {
387 (t, d)
388 } else {
389 anyhow::bail!("aborting issue creation due to empty title or description");
390 };
391 let issue = cache.create(
392 title,
393 description,
394 labels.as_slice(),
395 assignees.as_slice(),
396 [],
397 signer,
398 )?;
399
400 if !quiet {
401 term::issue::show(&issue, issue.id(), Format::Header, verbose, profile)?;
402 }
403 Ok(())
404}
405
406fn edit<'a, 'g, R, G>(
407 issues: &'g mut issue::Cache<issue::Issues<'a, R>, cob::cache::StoreWriter>,
408 repo: &storage::git::Repository,
409 id: Rev,
410 title: Option<Title>,
411 description: Option<String>,
412 signer: &Device<G>,
413) -> anyhow::Result<issue::IssueMut<'a, 'g, R, cob::cache::StoreWriter>>
414where
415 R: WriteRepository + cob::Store<Namespace = NodeId>,
416 G: crypto::signature::Signer<crypto::Signature>,
417{
418 let id = id.resolve(&repo.backend)?;
419 let mut issue = issues.get_mut(&id)?;
420 let (root, _) = issue.root();
421 let comment_id = *root;
422
423 if title.is_some() || description.is_some() {
424 issue.transaction("Edit", signer, |tx| {
426 if let Some(t) = title {
427 tx.edit(t)?;
428 }
429 if let Some(d) = description {
430 tx.edit_comment(comment_id, d, vec![])?;
431 }
432 Ok(())
433 })?;
434 return Ok(issue);
435 }
436
437 let Some((title, description)) = term::issue::get_title_description(
439 title.or_else(|| Title::new(issue.title()).ok()),
440 Some(description.unwrap_or(issue.description().to_owned())),
441 )?
442 else {
443 return Ok(issue);
444 };
445
446 issue.transaction("Edit", signer, |tx| {
447 tx.edit(title)?;
448 tx.edit_comment(comment_id, description, vec![])?;
449
450 Ok(())
451 })?;
452
453 Ok(issue)
454}