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
33const 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| {
126 Error::with_hint(e, "reset the cache with `rad issue cache` and try again")
127 })?
128 .context("No issue with the given ID exists")?;
129 term::issue::show(&issue, &id, format, args.verbose, &profile)?;
130 }
131 Command::State { id, target_state } => {
132 let to: StateArg = target_state.into();
133 let id = id.resolve(&repo.backend)?;
134 let signer = term::signer(&profile)?;
135 let mut issue = issues.get_mut(&id)?;
136 let state = to.into();
137 issue.lifecycle(state, &signer)?;
138
139 if !args.quiet {
140 let success =
141 |status| term::success!("Issue {} is now {status}", term::format::cob(&id));
142 match state {
143 State::Closed { reason } => match reason {
144 CloseReason::Other => success("closed"),
145 CloseReason::Solved => success("solved"),
146 },
147 State::Open => success("open"),
148 };
149 }
150 }
151 Command::React {
152 id,
153 reaction,
154 comment_id,
155 } => {
156 let id = id.resolve(&repo.backend)?;
157 if let Ok(mut issue) = issues.get_mut(&id) {
158 let signer = term::signer(&profile)?;
159 let comment_id = match comment_id {
160 Some(cid) => cid.resolve(&repo.backend)?,
161 None => *term::io::comment_select(&issue).map(|(cid, _)| cid)?,
162 };
163 let reaction = match reaction {
164 Some(reaction) => reaction,
165 None => term::io::reaction_select()?,
166 };
167 issue.react(comment_id, reaction, true, &signer)?;
168 }
169 }
170 Command::Assign { id, add, delete } => {
171 let signer = term::signer(&profile)?;
172 let id = id.resolve(&repo.backend)?;
173 let Ok(mut issue) = issues.get_mut(&id) else {
174 anyhow::bail!("Issue `{id}` not found");
175 };
176 let assignees = issue
177 .assignees()
178 .filter(|did| !delete.contains(did))
179 .chain(add.iter())
180 .cloned()
181 .collect::<Vec<_>>();
182 issue.assign(assignees, &signer)?;
183 }
184 Command::Label { id, add, delete } => {
185 let id = id.resolve(&repo.backend)?;
186 let Ok(mut issue) = issues.get_mut(&id) else {
187 anyhow::bail!("Issue `{id}` not found");
188 };
189 let labels = issue
190 .labels()
191 .filter(|did| !delete.contains(did))
192 .chain(add.iter())
193 .cloned()
194 .collect::<Vec<_>>();
195 let signer = term::signer(&profile)?;
196 issue.label(labels, &signer)?;
197 }
198 Command::List(list_args) => {
199 list(
200 issues,
201 &list_args.assigned,
202 &((&list_args.state).into()),
203 &profile,
204 args.verbose,
205 )?;
206 }
207 Command::Delete { id } => {
208 let id = id.resolve(&repo.backend)?;
209 let signer = term::signer(&profile)?;
210 issues.remove(&id, &signer)?;
211 }
212 Command::Cache { id, storage } => {
213 let mode = if storage {
214 cache::CacheMode::Storage
215 } else {
216 let issue_id = id.map(|id| id.resolve(&repo.backend)).transpose()?;
217 issue_id.map_or(cache::CacheMode::Repository { repository: &repo }, |id| {
218 cache::CacheMode::Issue {
219 id,
220 repository: &repo,
221 }
222 })
223 };
224 cache::run(mode, &profile)?;
225 }
226 }
227
228 if announce {
229 let mut node = Node::new(profile.socket());
230 node::announce(
231 &repo,
232 node::SyncSettings::default(),
233 node::SyncReporting::default(),
234 &mut node,
235 &profile,
236 )?;
237 }
238
239 Ok(())
240}
241
242fn list<C>(
243 cache: C,
244 assigned: &Option<Assigned>,
245 state: &Option<State>,
246 profile: &profile::Profile,
247 verbose: bool,
248) -> anyhow::Result<()>
249where
250 C: issue::cache::Issues,
251{
252 if cache.is_empty()? {
253 term::print(term::format::italic("Nothing to show."));
254 return Ok(());
255 }
256
257 let assignee = match assigned {
258 Some(Assigned::Me) => Some(*profile.id()),
259 Some(Assigned::Peer(id)) => Some((*id).into()),
260 None => None,
261 };
262
263 let mut all = cache
264 .list()?
265 .filter_map(|result| {
266 let (id, issue) = match result {
267 Ok((id, issue)) => (id, issue),
268 Err(e) => {
269 log::error!(target: "cli", "Issue load error: {e}");
271 return None;
272 }
273 };
274
275 if let Some(a) = assignee {
276 if !issue.assignees().any(|v| v == &Did::from(a)) {
277 return None;
278 }
279 }
280
281 if let Some(s) = state {
282 if s != issue.state() {
283 return None;
284 }
285 }
286
287 Some((id, issue))
288 })
289 .collect::<Vec<_>>();
290
291 all.sort_by(|(id1, i1), (id2, i2)| {
292 let by_timestamp = i2.timestamp().cmp(&i1.timestamp());
293 let by_id = id1.cmp(id2);
294
295 by_timestamp.then(by_id)
296 });
297
298 let mut table = term::Table::new(term::table::TableOptions::bordered());
299 table.header([
300 term::format::dim(String::from("●")).into(),
301 term::format::bold(String::from("ID")).into(),
302 term::format::bold(String::from("Title")).into(),
303 term::format::bold(String::from("Author")).into(),
304 term::Line::blank(),
305 term::format::bold(String::from("Labels")).into(),
306 term::format::bold(String::from("Assignees")).into(),
307 term::format::bold(String::from("Opened")).into(),
308 ]);
309 table.divider();
310
311 table.extend(all.into_iter().map(|(id, issue)| {
312 let assigned: String = issue
313 .assignees()
314 .map(|did| {
315 let (alias, _) = Author::new(did.as_key(), profile, verbose).labels();
316
317 alias.content().to_owned()
318 })
319 .collect::<Vec<_>>()
320 .join(", ");
321
322 let mut labels = issue.labels().map(|t| t.to_string()).collect::<Vec<_>>();
323 labels.sort();
324
325 let author = issue.author().id;
326 let (alias, did) = Author::new(&author, profile, verbose).labels();
327
328 mk_issue_row(id, issue, assigned, labels, alias, did)
329 }));
330
331 table.print();
332
333 Ok(())
334}
335
336fn mk_issue_row(
337 id: cob::ObjectId,
338 issue: issue::Issue,
339 assigned: String,
340 labels: Vec<String>,
341 alias: radicle_term::Label,
342 did: radicle_term::Label,
343) -> [radicle_term::Line; 8] {
344 [
345 match issue.state() {
346 State::Open => term::format::positive("●").into(),
347 State::Closed { .. } => term::format::negative("●").into(),
348 },
349 term::format::tertiary(term::format::cob(&id))
350 .to_owned()
351 .into(),
352 term::format::default(issue.title().to_owned()).into(),
353 alias.into(),
354 did.into(),
355 term::format::secondary(labels.join(", ")).into(),
356 if assigned.is_empty() {
357 term::format::dim(String::default()).into()
358 } else {
359 term::format::primary(assigned.to_string()).dim().into()
360 },
361 term::format::timestamp(issue.timestamp())
362 .dim()
363 .italic()
364 .into(),
365 ]
366}
367
368fn open<R, G>(
369 title: Option<Title>,
370 description: Option<String>,
371 labels: Vec<Label>,
372 assignees: Vec<Did>,
373 verbose: bool,
374 quiet: bool,
375 cache: &mut issue::Cache<issue::Issues<'_, R>, cob::cache::StoreWriter>,
376 signer: &Device<G>,
377 profile: &Profile,
378) -> anyhow::Result<()>
379where
380 R: WriteRepository + cob::Store<Namespace = NodeId>,
381 G: crypto::signature::Signer<crypto::Signature>,
382{
383 let (title, description) = if let (Some(t), Some(d)) = (title.as_ref(), description.as_ref()) {
384 (t.to_owned(), d.to_owned())
385 } else if let Some((t, d)) = term::issue::get_title_description(title, description)? {
386 (t, d)
387 } else {
388 anyhow::bail!("aborting issue creation due to empty title or description");
389 };
390 let issue = cache.create(
391 title,
392 description,
393 labels.as_slice(),
394 assignees.as_slice(),
395 [],
396 signer,
397 )?;
398
399 if !quiet {
400 term::issue::show(&issue, issue.id(), Format::Header, verbose, profile)?;
401 }
402 Ok(())
403}
404
405fn edit<'a, 'g, R, G>(
406 issues: &'g mut issue::Cache<issue::Issues<'a, R>, cob::cache::StoreWriter>,
407 repo: &storage::git::Repository,
408 id: Rev,
409 title: Option<Title>,
410 description: Option<String>,
411 signer: &Device<G>,
412) -> anyhow::Result<issue::IssueMut<'a, 'g, R, cob::cache::StoreWriter>>
413where
414 R: WriteRepository + cob::Store<Namespace = NodeId>,
415 G: crypto::signature::Signer<crypto::Signature>,
416{
417 let id = id.resolve(&repo.backend)?;
418 let mut issue = issues.get_mut(&id)?;
419 let (root, _) = issue.root();
420 let comment_id = *root;
421
422 if title.is_some() || description.is_some() {
423 issue.transaction("Edit", signer, |tx| {
425 if let Some(t) = title {
426 tx.edit(t)?;
427 }
428 if let Some(d) = description {
429 tx.edit_comment(comment_id, d, vec![])?;
430 }
431 Ok(())
432 })?;
433 return Ok(issue);
434 }
435
436 let Some((title, description)) = term::issue::get_title_description(
438 title.or_else(|| Title::new(issue.title()).ok()),
439 Some(description.unwrap_or(issue.description().to_owned())),
440 )?
441 else {
442 return Ok(issue);
443 };
444
445 issue.transaction("Edit", signer, |tx| {
446 tx.edit(title)?;
447 tx.edit_comment(comment_id, description, vec![])?;
448
449 Ok(())
450 })?;
451
452 Ok(issue)
453}