Radish alpha
h
rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
Radicle Heartwood Protocol & Stack
Radicle
Git
heartwood crates radicle-cli src commands issue.rs
mod args;
mod cache;
mod comment;

use anyhow::Context as _;

use radicle::cob::common::Label;
use radicle::cob::issue::{CloseReason, State};
use radicle::cob::store::access::WriteAs;
use radicle::cob::{Title, issue};

use radicle::Profile;
use radicle::crypto;
use radicle::issue::cache::Issues as _;
use radicle::node::NodeId;
use radicle::prelude::Did;
use radicle::profile;
use radicle::storage;
use radicle::storage::{WriteRepository, WriteStorage};
use radicle::{Node, cob};

pub use args::Args;
use args::{Assigned, Command, CommentAction, StateArg};

use crate::git::Rev;
use crate::node;
use crate::terminal as term;
use crate::terminal::Element;
use crate::terminal::args::{Error, rid_or_cwd};
use crate::terminal::format::Author;
use crate::terminal::issue::Format;

const ABOUT: &str = "Manage issues";

pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
    let profile = ctx.profile()?;
    let (_, rid) = rid_or_cwd(args.repo)?;
    let repo = profile.storage.repository_mut(rid)?;

    // Fallback to [`Command::List`] if no subcommand is provided.
    // Construct it using the [`EmptyArgs`] in `args.empty`.
    let command = args
        .command
        .unwrap_or_else(|| Command::List(args.empty.into()));

    let announce = !args.no_announce && command.should_announce_for();
    let signer = profile.signer()?;
    let mut issues = term::cob::issues_mut(&profile, &repo, &signer)?;

    match command {
        Command::Edit {
            id,
            title,
            description,
        } => {
            let issue = edit(&mut issues, &repo, id, title, description)?;
            if !args.quiet {
                term::issue::show(&issue, issue.id(), Format::Header, args.verbose, &profile)?;
            }
        }
        Command::Open {
            title,
            description,
            labels,
            assignees,
        } => {
            open(
                title,
                description,
                labels,
                assignees,
                args.verbose,
                args.quiet,
                &mut issues,
                &profile,
            )?;
        }
        Command::Comment(c) => match CommentAction::from(c) {
            CommentAction::Comment { id, message } => {
                comment::comment(&profile, &repo, &mut issues, id, message, None, args.quiet)?;
            }
            CommentAction::Reply {
                id,
                message,
                reply_to,
            } => comment::comment(
                &profile,
                &repo,
                &mut issues,
                id,
                message,
                Some(reply_to),
                args.quiet,
            )?,
            CommentAction::Edit {
                id,
                message,
                to_edit,
            } => comment::edit(
                &profile,
                &repo,
                &mut issues,
                id,
                message,
                to_edit,
                args.quiet,
            )?,
        },
        Command::Show { id } => {
            let format = if args.header {
                term::issue::Format::Header
            } else {
                term::issue::Format::Full
            };

            let id = id.resolve(&repo.backend)?;
            let issue = issues
                .get(&id)
                .map_err(|e| {
                    Error::with_hint(e, "reset the cache with `rad issue cache` and try again")
                })?
                .context("No issue with the given ID exists")?;
            term::issue::show(&issue, &id, format, args.verbose, &profile)?;
        }
        Command::State { id, target_state } => {
            let to: StateArg = target_state.into();
            let id = id.resolve(&repo.backend)?;
            let mut issue = issues.get_mut(&id)?;
            let state = to.into();
            issue.lifecycle(state)?;

            if !args.quiet {
                let success =
                    |status| term::success!("Issue {} is now {status}", term::format::cob(&id));
                match state {
                    State::Closed { reason } => match reason {
                        CloseReason::Other => success("closed"),
                        CloseReason::Solved => success("solved"),
                    },
                    State::Open => success("open"),
                };
            }
        }
        Command::React {
            id,
            reaction,
            comment_id,
        } => {
            let id = id.resolve(&repo.backend)?;
            if let Ok(mut issue) = issues.get_mut(&id) {
                let comment_id = match comment_id {
                    Some(cid) => cid.resolve(&repo.backend)?,
                    None => *term::io::comment_select(&issue).map(|(cid, _)| cid)?,
                };
                let reaction = match reaction {
                    Some(reaction) => reaction,
                    None => term::io::reaction_select()?,
                };
                issue.react(comment_id, reaction, true)?;
            }
        }
        Command::Assign { id, add, delete } => {
            let id = id.resolve(&repo.backend)?;
            let Ok(mut issue) = issues.get_mut(&id) else {
                anyhow::bail!("Issue `{id}` not found");
            };
            let assignees = issue
                .assignees()
                .filter(|did| !delete.contains(did))
                .chain(add.iter())
                .cloned()
                .collect::<Vec<_>>();
            issue.assign(assignees)?;
        }
        Command::Label { id, add, delete } => {
            let id = id.resolve(&repo.backend)?;
            let Ok(mut issue) = issues.get_mut(&id) else {
                anyhow::bail!("Issue `{id}` not found");
            };
            let labels = issue
                .labels()
                .filter(|did| !delete.contains(did))
                .chain(add.iter())
                .cloned()
                .collect::<Vec<_>>();
            issue.label(labels)?;
        }
        Command::List(list_args) => {
            list(
                issues,
                &list_args.assigned,
                &((&list_args.state).into()),
                &profile,
                args.verbose,
            )?;
        }
        Command::Delete { id } => {
            let id = id.resolve(&repo.backend)?;
            issues.remove(&id)?;
        }
        Command::Cache { id, storage } => {
            let mode = if storage {
                cache::CacheMode::Storage
            } else {
                let issue_id = id.map(|id| id.resolve(&repo.backend)).transpose()?;
                issue_id.map_or(cache::CacheMode::Repository { repository: &repo }, |id| {
                    cache::CacheMode::Issue {
                        id,
                        repository: &repo,
                    }
                })
            };
            cache::run(mode, &profile)?;
        }
    }

    if announce {
        let mut node = Node::new(profile.socket_from_env());
        node::announce(
            &repo,
            node::SyncSettings::default(),
            node::SyncReporting::default(),
            &mut node,
            &profile,
        )?;
    }

    Ok(())
}

fn list<C>(
    cache: C,
    assigned: &Option<Assigned>,
    state: &Option<State>,
    profile: &profile::Profile,
    verbose: bool,
) -> anyhow::Result<()>
where
    C: issue::cache::Issues,
{
    if cache.is_empty()? {
        term::println(term::format::italic("Nothing to show."));
        return Ok(());
    }

    let assignee = match assigned {
        Some(Assigned::Me) => Some(*profile.id()),
        Some(Assigned::Peer(id)) => Some((*id).into()),
        None => None,
    };

    let mut all = cache
        .list()?
        .filter_map(|result| {
            let (id, issue) = match result {
                Ok((id, issue)) => (id, issue),
                Err(e) => {
                    // Skip issues that failed to load.
                    log::error!(target: "cli", "Issue load error: {e}");
                    return None;
                }
            };

            if let Some(a) = assignee {
                if !issue.assignees().any(|v| v == &Did::from(a)) {
                    return None;
                }
            }

            if let Some(s) = state {
                if s != issue.state() {
                    return None;
                }
            }

            Some((id, issue))
        })
        .collect::<Vec<_>>();

    all.sort_by(|(id1, i1), (id2, i2)| {
        let by_timestamp = i2.timestamp().cmp(&i1.timestamp());
        let by_id = id1.cmp(id2);

        by_timestamp.then(by_id)
    });

    let mut table = term::Table::new(term::table::TableOptions::bordered());
    table.header([
        term::format::dim(String::from("●")).into(),
        term::format::bold(String::from("ID")).into(),
        term::format::bold(String::from("Title")).into(),
        term::format::bold(String::from("Author")).into(),
        term::Line::blank(),
        term::format::bold(String::from("Labels")).into(),
        term::format::bold(String::from("Assignees")).into(),
        term::format::bold(String::from("Opened")).into(),
    ]);
    table.divider();

    table.extend(all.into_iter().map(|(id, issue)| {
        let assigned: String = issue
            .assignees()
            .map(|did| {
                let (alias, _) = Author::new(did.as_key(), profile, verbose).labels();

                alias.content().to_owned()
            })
            .collect::<Vec<_>>()
            .join(", ");

        let mut labels = issue.labels().map(|t| t.to_string()).collect::<Vec<_>>();
        labels.sort();

        let author = issue.author().id;
        let (alias, did) = Author::new(&author, profile, verbose).labels();

        mk_issue_row(id, issue, assigned, labels, alias, did)
    }));

    table.print();

    Ok(())
}

fn mk_issue_row(
    id: cob::ObjectId,
    issue: issue::Issue,
    assigned: String,
    labels: Vec<String>,
    alias: radicle_term::Label,
    did: radicle_term::Label,
) -> [radicle_term::Line; 8] {
    [
        match issue.state() {
            State::Open => term::format::positive("●").into(),
            State::Closed { .. } => term::format::negative("●").into(),
        },
        term::format::tertiary(term::format::cob(&id))
            .to_owned()
            .into(),
        term::format::default(issue.title().to_owned()).into(),
        alias.into(),
        did.into(),
        term::format::secondary(labels.join(", ")).into(),
        if assigned.is_empty() {
            term::format::dim(String::default()).into()
        } else {
            term::format::primary(assigned.to_string()).dim().into()
        },
        term::format::timestamp(issue.timestamp())
            .dim()
            .italic()
            .into(),
    ]
}

fn open<Repo, Signer>(
    title: Option<Title>,
    description: Option<String>,
    labels: Vec<Label>,
    assignees: Vec<Did>,
    verbose: bool,
    quiet: bool,
    cache: &mut issue::Cache<'_, Repo, WriteAs<'_, Signer>, cob::cache::StoreWriter>,
    profile: &Profile,
) -> anyhow::Result<()>
where
    Repo: WriteRepository + cob::Store<Namespace = NodeId>,
    Signer: crypto::signature::Keypair<VerifyingKey = crypto::PublicKey>,
    Signer: crypto::signature::Signer<crypto::Signature>,
    Signer: crypto::signature::Signer<crypto::ssh::ExtendedSignature>,
    Signer: crypto::signature::Verifier<crypto::Signature>,
{
    let (title, description) = if let (Some(t), Some(d)) = (title.as_ref(), description.as_ref()) {
        (t.to_owned(), d.to_owned())
    } else if let Some((t, d)) = term::issue::get_title_description(title, description)? {
        (t, d)
    } else {
        anyhow::bail!("aborting issue creation due to empty title or description");
    };
    let issue = cache.create(
        title,
        description,
        labels.as_slice(),
        assignees.as_slice(),
        [],
    )?;

    if !quiet {
        term::issue::show(&issue, issue.id(), Format::Header, verbose, profile)?;
    }
    Ok(())
}

fn edit<'a, 'b, 'g, Repo, Signer>(
    issues: &'g mut issue::Cache<'a, Repo, WriteAs<'b, Signer>, cob::cache::StoreWriter>,
    repo: &storage::git::Repository,
    id: Rev,
    title: Option<Title>,
    description: Option<String>,
) -> anyhow::Result<issue::IssueMut<'a, 'b, 'g, Repo, Signer, cob::cache::StoreWriter>>
where
    Repo: WriteRepository + cob::Store<Namespace = NodeId>,
    Signer: crypto::signature::Keypair<VerifyingKey = crypto::PublicKey>,
    Signer: crypto::signature::Signer<crypto::Signature>,
    Signer: radicle::crypto::signature::Signer<radicle::crypto::ssh::ExtendedSignature>,
    Signer: crypto::signature::Verifier<crypto::Signature>,
{
    let id = id.resolve(&repo.backend)?;
    let mut issue = issues.get_mut(&id)?;
    let (root, _) = issue.root();
    let comment_id = *root;

    if title.is_some() || description.is_some() {
        // Editing by command line arguments.
        issue.transaction("Edit", |tx| {
            if let Some(t) = title {
                tx.edit(t)?;
            }
            if let Some(d) = description {
                tx.edit_comment(comment_id, d, vec![])?;
            }
            Ok(())
        })?;
        return Ok(issue);
    }

    // Editing via the editor.
    let Some((title, description)) = term::issue::get_title_description(
        title.or_else(|| Title::new(issue.title()).ok()),
        Some(description.unwrap_or(issue.description().to_owned())),
    )?
    else {
        return Ok(issue);
    };

    issue.transaction("Edit", |tx| {
        tx.edit(title)?;
        tx.edit_comment(comment_id, description, vec![])?;

        Ok(())
    })?;

    Ok(issue)
}