codeberg-cli 0.5.5

CLI Tool for codeberg similar to gh and glab
Documentation
use crate::actions::GlobalArgs;
use crate::render::json::JsonToStdout;
use crate::render::ui::{fuzzy_select_with_key, multi_fuzzy_select_with_key};
use crate::types::context::BergContext;
use crate::types::git::OwnerRepo;
use crate::types::output::OutputMode;
use forgejo_api::structs::{CreateIssueOption, IssueGetMilestonesListQuery, IssueListLabelsQuery};
use miette::{Context, IntoDiagnostic};
use strum::*;

use crate::actions::text_manipulation::{input_prompt_for, select_prompt_for};

use clap::Parser;

/// Create an issue
#[derive(Parser, Debug)]
pub struct CreateIssueArgs {
    /// Title or summary
    #[arg(short, long)]
    pub title: Option<String>,

    /// Comma-delimited list of label ids
    #[arg(short, long, value_name = "LABEL,...", value_delimiter = ',')]
    pub labels: Option<Vec<String>>,

    /// Main description of issue
    #[arg(id = "description", short, long)]
    pub body: Option<String>,

    /// Comma-delimited list of assignee names
    #[arg(short, long, value_name = "ASSIGNEE,...", value_delimiter = ',')]
    pub assignees: Option<Vec<String>>,

    /// Name of the milestone the issue is related to
    #[arg(short, long)]
    pub milestone: Option<String>,
}

#[derive(Display, PartialEq, Eq, VariantArray)]
enum CreatableFields {
    Labels,
    Assignees,
    Milestone,
}

impl CreateIssueArgs {
    pub async fn run(self, global_args: GlobalArgs) -> miette::Result<()> {
        let ctx = BergContext::new(self, global_args).await?;
        let OwnerRepo { repo, owner } = ctx.owner_repo()?;
        let options = create_options(&ctx).await?;
        tracing::debug!("{options:?}");
        let issue = ctx
            .client
            .issue_create_issue(owner.as_str(), repo.as_str(), options)
            .await
            .into_diagnostic()?;
        match ctx.global_args.output_mode {
            OutputMode::Pretty => {
                tracing::debug!("{issue:?}");
            }
            OutputMode::Json => issue.print_json()?,
        }
        Ok(())
    }
}

async fn create_options(ctx: &BergContext<CreateIssueArgs>) -> miette::Result<CreateIssueOption> {
    let OwnerRepo { repo, owner } = ctx.owner_repo()?;
    let title = ctx
        .args
        .title
        .as_ref()
        .cloned()
        .map(Ok)
        .unwrap_or_else(|| {
            if ctx.global_args.non_interactive {
                miette::bail!("You have to set an issue title!");
            } else {
                inquire::Text::new(input_prompt_for("Issue Title").as_str())
                    .prompt()
                    .into_diagnostic()
            }
        })?;

    let milestone = if let Some(milestone_title) = ctx.args.milestone.as_ref() {
        let (_, milestones) = ctx
            .client
            .issue_get_milestones_list(owner.as_str(), repo.as_str(), Default::default())
            .await
            .into_diagnostic()?;
        let selected_milestone = milestones
            .into_iter()
            .find(|milestone| {
                milestone
                    .title
                    .as_ref()
                    .is_some_and(|title| title == milestone_title)
            })
            .with_context(|| format!("Couldn't find milestone with title: {milestone_title}"))?;
        Some(
            selected_milestone
                .id
                .context("Milestone is expected to have a milestone ID!")?,
        )
    } else {
        None
    };

    let labels = if let Some(requested_labels) = ctx.args.labels.as_ref() {
        let (_, issue_labels) = ctx
            .client
            .issue_list_labels(owner.as_str(), repo.as_str(), Default::default())
            .await
            .into_diagnostic()?;
        let labels = requested_labels
            .iter()
            .map(|label_name| {
                issue_labels
                    .iter()
                    .find(|label| label.name.as_ref().is_some_and(|name| name == label_name))
                    .with_context(|| format!("Label with name {label_name} doesn't exist yet"))
                    .context("Please create all labels before using them")?
                    .id
                    .context("Selected label is missing an ID")
            })
            .collect::<Result<Vec<_>, _>>()?;
        Some(labels)
    } else {
        None
    };

    let mut options = CreateIssueOption {
        title,
        assignee: None,
        assignees: ctx.args.assignees.clone(),
        body: ctx.args.body.clone(),
        closed: None,
        due_date: None,
        labels,
        milestone,
        r#ref: None,
    };

    options.body.replace(issue_body(ctx)?);

    if !ctx.global_args.non_interactive {
        let optional_data = {
            use CreatableFields::*;
            [
                (Labels, ctx.args.labels.is_none()),
                (Assignees, ctx.args.assignees.is_none()),
                (Milestone, ctx.args.milestone.is_none()),
            ]
            .into_iter()
            .filter_map(|(name, missing)| missing.then_some(name))
            .collect::<Vec<_>>()
        };

        let chosen_optionals = multi_fuzzy_select_with_key(
            &optional_data,
            "Choose optional properties",
            |_| false,
            |o| o.to_string(),
        )?;
        {
            use CreatableFields::*;
            options.labels = issue_labels(ctx, chosen_optionals.contains(&&Labels)).await?;
            options.assignees =
                issue_assignees(ctx, chosen_optionals.contains(&&Assignees)).await?;
            options.milestone =
                issue_milestone(ctx, chosen_optionals.contains(&&Milestone)).await?;
        }
    }

    Ok(options)
}

fn issue_body(ctx: &BergContext<CreateIssueArgs>) -> miette::Result<String> {
    let body = match ctx.args.body.as_ref() {
        Some(body) => body.clone(),
        None => {
            if ctx.global_args.non_interactive {
                miette::bail!("You need to provide an issue description!");
            } else {
                ctx.editor_for("a description", "Enter an issue description")?
            }
        }
    };
    Ok(body)
}

async fn issue_labels(
    ctx: &BergContext<CreateIssueArgs>,
    interactive: bool,
) -> miette::Result<Option<Vec<i64>>> {
    let OwnerRepo { repo, owner } = ctx.owner_repo()?;

    let (_, all_labels) = ctx
        .client
        .issue_list_labels(
            owner.as_str(),
            repo.as_str(),
            IssueListLabelsQuery::default(),
        )
        .await
        .into_diagnostic()?;

    let labels = match &ctx.args.labels {
        Some(label_names) => {
            let label_ids = all_labels
                .iter()
                .filter(|l| {
                    l.name
                        .as_ref()
                        .is_some_and(|name| label_names.contains(name))
                })
                .filter_map(|l| l.id)
                .collect::<Vec<_>>();
            Some(label_ids)
        }
        None => {
            if !interactive {
                return Ok(None);
            }

            if all_labels.is_empty() {
                println!(
                    "No labels available! You might want to create some via `berg label create`!"
                );
                return Ok(None);
            }

            let selected_labels = multi_fuzzy_select_with_key(
                &all_labels,
                select_prompt_for("labels"),
                |_| false,
                |l| {
                    l.name
                        .as_ref()
                        .cloned()
                        .unwrap_or_else(|| String::from("???"))
                },
            )?;

            let label_ids = selected_labels
                .iter()
                .filter_map(|l| l.id)
                .collect::<Vec<_>>();

            Some(label_ids)
        }
    };
    Ok(labels)
}

async fn issue_assignees(
    ctx: &BergContext<CreateIssueArgs>,
    interactive: bool,
) -> miette::Result<Option<Vec<String>>> {
    let OwnerRepo { repo, owner } = ctx.owner_repo()?;

    let (_, all_assignees) = ctx
        .client
        .repo_get_assignees(owner.as_str(), repo.as_str())
        .await
        .into_diagnostic()?;
    let assignees = match &ctx.args.assignees {
        Some(assignees_names) => Some(assignees_names.clone()),
        None => {
            tracing::debug!("Hey");
            if !interactive {
                return Ok(None);
            }

            let selected_assignees = multi_fuzzy_select_with_key(
                &all_assignees,
                select_prompt_for("assignees"),
                |_| false,
                |u| {
                    u.login
                        .as_ref()
                        .cloned()
                        .unwrap_or_else(|| String::from("???"))
                },
            )?;

            Some(
                selected_assignees
                    .into_iter()
                    .filter_map(|u| u.login.as_ref().cloned())
                    .collect::<Vec<_>>(),
            )
        }
    };

    Ok(assignees)
}

async fn issue_milestone(
    ctx: &BergContext<CreateIssueArgs>,
    interactive: bool,
) -> miette::Result<Option<i64>> {
    let OwnerRepo { repo, owner } = ctx.owner_repo()?;

    let (_, all_milestones) = ctx
        .client
        .issue_get_milestones_list(
            owner.as_str(),
            repo.as_str(),
            IssueGetMilestonesListQuery::default(),
        )
        .await
        .into_diagnostic()?;

    let milestone = match &ctx.args.milestone {
        Some(milestone_name) => Some(
            all_milestones
                .iter()
                .find(|m| m.title.as_ref().is_some_and(|name| name == milestone_name))
                .and_then(|milestone| milestone.id)
                .context(format!(
                    "Milestone with name {milestone_name} wasn't found. Check the spelling"
                ))?,
        ),
        None => {
            if !interactive {
                return Ok(None);
            }
            let selected_milestone =
                fuzzy_select_with_key(&all_milestones, select_prompt_for("milestones"), |m| {
                    m.title
                        .as_ref()
                        .cloned()
                        .unwrap_or_else(|| String::from("???"))
                })?;

            Some(
                selected_milestone
                    .id
                    .context("Selected milestone's id is missing")?,
            )
        }
    };

    Ok(milestone)
}