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