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