radicle_cli/commands/
issue.rs

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    // Fallback to [`Command::List`] if no subcommand is provided.
45    // Construct it using the [`EmptyArgs`] in `args.empty`.
46    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                    // Skip issues that failed to load.
271                    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        // Editing by command line arguments.
425        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    // Editing via the editor.
438    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}