codeberg-cli 0.5.5

CLI Tool for codeberg similar to gh and glab
Documentation
use crate::actions::GlobalArgs;
use crate::actions::issue::display_issue;
use crate::render::json::JsonToStdout;
use crate::render::option::option_display;
use crate::render::spinner::spin_until_ready;
use crate::render::ui::{fuzzy_select_with_key, multi_fuzzy_select_with_key, select_state};
use crate::types::api::state_type::ExclusiveStateType;
use crate::types::context::BergContext;
use crate::types::git::OwnerRepo;
use crate::types::output::OutputMode;
use forgejo_api::structs::{
    EditIssueOption, Issue, IssueLabelsOption, IssueListIssuesQuery, IssueListLabelsQuery,
};
use miette::{Context, IntoDiagnostic};
use strum::{Display, VariantArray};

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

use clap::Parser;

/// Edit selected issue
#[derive(Parser, Debug)]
pub struct EditIssueArgs {
    /// The id of the issue that's going to be edited
    pub id: Option<i64>,

    /// Change title or summary
    #[arg(short, long)]
    pub title: Option<String>,

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

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

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

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

    /// Changes the state of the issue
    #[arg(short, long, value_enum)]
    pub state: Option<ExclusiveStateType>,
}

#[derive(Display, PartialEq, Eq, VariantArray)]
enum EditableFields {
    Assignees,
    Description,
    State,
    Title,
    Labels,
}

impl EditIssueArgs {
    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 issue = select_issue(&ctx).await?;
        let issue_id = issue.number.context("Selected issue doesn't have an ID")?;

        let (options, label_options) = create_options(&ctx, &issue).await?;

        let updated_labels = ctx
            .client
            .issue_replace_labels(owner.as_str(), repo.as_str(), issue_id, label_options)
            .await
            .into_diagnostic()?;

        tracing::debug!("{updated_labels:?}");

        let updated_issue = ctx
            .client
            .issue_edit_issue(owner.as_str(), repo.as_str(), issue_id, options)
            .await
            .into_diagnostic()?;

        match ctx.global_args.output_mode {
            OutputMode::Pretty => {
                tracing::debug!("{updated_issue:?}");
            }
            OutputMode::Json => updated_issue.print_json()?,
        }

        Ok(())
    }
}

async fn select_issue(ctx: &BergContext<EditIssueArgs>) -> miette::Result<Issue> {
    let OwnerRepo { repo, owner } = ctx.owner_repo()?;
    if let Some(index) = ctx.args.id {
        ctx.client
            .issue_get_issue(owner.as_str(), repo.as_str(), index)
            .await
            .into_diagnostic()
            .with_context(|| format!("Couldn't find selected issue #{index} in repo"))
    } else {
        if ctx.global_args.non_interactive {
            miette::bail!("non-interactive mode enabled. You have to specify an issue ID");
        };
        let (_, issues_list) = spin_until_ready(
            ctx.client
                .issue_list_issues(
                    owner.as_str(),
                    repo.as_str(),
                    IssueListIssuesQuery::default(),
                )
                .send(),
        )
        .await
        .into_diagnostic()?;

        fuzzy_select_with_key(&issues_list, select_prompt_for("issue"), display_issue).cloned()
    }
}

async fn create_options(
    ctx: &BergContext<EditIssueArgs>,
    issue: &Issue,
) -> miette::Result<(EditIssueOption, IssueLabelsOption)> {
    let OwnerRepo { repo, owner } = ctx.owner_repo()?;

    // we have the milestone name but we need it's id ... do some extra steps to retrieve it
    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 state = ctx.args.state.as_ref().map(ToString::to_string);

    let labels = ctx
        .args
        .labels
        .iter()
        .cloned()
        .map(serde_json::Value::String)
        .collect::<Vec<_>>();
    let labels = (!labels.is_empty())
        .then_some(labels)
        .or(issue.labels.as_ref().and_then(|labels| {
            let labels = labels
                .iter()
                .filter_map(|label| label.id)
                .map(serde_json::Number::from)
                .map(serde_json::Value::Number)
                .collect::<Vec<_>>();
            (!labels.is_empty()).then_some(labels)
        }));

    let assignees = ctx.args.assignees.clone();
    let assignees = (!assignees.is_empty()).then_some(assignees).or(issue
        .assignees
        .as_ref()
        .and_then(|users| {
            let users = users
                .iter()
                .filter_map(|user| user.login_name.clone())
                // we need to be careful, without any assignees issue.assignees returns [""] which is not a valid
                // login of any user
                .filter(|name| !name.is_empty())
                .collect::<Vec<_>>();
            (!users.is_empty()).then_some(users)
        }));

    let mut options = EditIssueOption {
        // deprecated
        assignee: None,
        assignees,
        body: ctx.args.body.clone().or(issue.body.clone()),
        due_date: issue.due_date,
        milestone: milestone.or(issue.milestone.as_ref().and_then(|milestone| milestone.id)),
        r#ref: issue.r#ref.clone(),
        state: state.or(issue
            .state
            .map(ExclusiveStateType::from)
            .as_ref()
            .map(ToString::to_string)),
        title: ctx.args.title.clone().or(issue.title.clone()),
        unset_due_date: None,
        updated_at: None,
    };

    let mut label_options = IssueLabelsOption {
        labels,
        updated_at: None,
    };

    // interactive part
    if !ctx.global_args.non_interactive {
        let selected_update_fields = multi_fuzzy_select_with_key(
            EditableFields::VARIANTS,
            select_prompt_for("options"),
            |_| false,
            |f| f.to_string(),
        )?;

        if selected_update_fields.contains(&&EditableFields::Assignees) {
            let current_assignees = issue
                .assignees
                .as_ref()
                .map(|users| users.iter().filter_map(|user| user.id).collect::<Vec<_>>());
            options
                .assignees
                .replace(issue_assignees(ctx, current_assignees).await?);
        }
        if selected_update_fields.contains(&&EditableFields::Description) {
            options
                .body
                .replace(issue_body(ctx, issue.body.clone()).await?);
        }
        if selected_update_fields.contains(&&EditableFields::State) {
            options.state.replace(select_state(issue.state)?);
        }
        if selected_update_fields.contains(&&EditableFields::Title) {
            options
                .title
                .replace(issue_title(ctx, issue.title.clone()).await?);
        }
        if selected_update_fields.contains(&&EditableFields::Labels) {
            let current_labels = issue.labels.as_ref().map(|labels| {
                labels
                    .iter()
                    .filter_map(|label| label.id)
                    .collect::<Vec<_>>()
            });
            label_options
                .labels
                .replace(issue_labels(ctx, current_labels).await?);
        }
    }

    // update time last so it's fresh
    let now = time::OffsetDateTime::now_utc();
    options.updated_at.replace(now);
    label_options.updated_at.replace(now);

    Ok((options, label_options))
}

async fn issue_assignees(
    ctx: &BergContext<EditIssueArgs>,
    current_assignees: Option<Vec<i64>>,
) -> miette::Result<Vec<String>> {
    let OwnerRepo { repo, owner } = ctx.owner_repo()?;
    let current_assignees = current_assignees.unwrap_or_default();
    let (_, all_assignees) = ctx
        .client
        .repo_get_assignees(owner.as_str(), repo.as_str())
        .await
        .into_diagnostic()?;
    let selected_assignees = multi_fuzzy_select_with_key(
        &all_assignees,
        select_prompt_for("assignees"),
        |u| u.id.is_some_and(|id| current_assignees.contains(&id)),
        |u| option_display(&u.login),
    )?;

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

async fn issue_body(
    ctx: &BergContext<EditIssueArgs>,
    current_body: Option<String>,
) -> miette::Result<String> {
    ctx.editor_for(
        "a description",
        current_body
            .as_deref()
            .unwrap_or("Enter an issue description"),
    )
}

async fn issue_title(
    _ctx: &BergContext<EditIssueArgs>,
    current_title: Option<String>,
) -> miette::Result<String> {
    inquire::Text::new(input_prompt_for("Choose a new issue title").as_str())
        .with_default(current_title.as_deref().unwrap_or("Enter an issue title"))
        .prompt()
        .into_diagnostic()
}

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

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

    let selected_labels = multi_fuzzy_select_with_key(
        &all_labels,
        select_prompt_for("labels"),
        |l| l.id.is_some_and(|id| current_labels.contains(&id)),
        |l| option_display(&l.name),
    )?;

    let label_ids = selected_labels
        .iter()
        .filter_map(|l| l.id)
        .map(|x| serde_json::json!(x))
        .collect::<Vec<_>>();

    Ok(label_ids)
}