Skip to main content

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
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    // 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| {
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                    // Skip issues that failed to load.
270                    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        // Editing by command line arguments.
424        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    // Editing via the editor.
437    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}