codeberg-cli 0.4.9

CLI Tool for codeberg similar to gh and glab
Documentation
use crate::actions::issue::display_issue;
use crate::actions::GeneralArgs;
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};
use crate::types::context::BergContext;
use crate::types::git::OwnerRepo;
use anyhow::Context;
use forgejo_api::structs::{
    EditIssueOption, Issue, IssueLabelsOption, IssueListIssuesQuery, IssueListLabelsQuery,
    StateType,
};
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 {}

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

impl EditIssueArgs {
    pub async fn run(self, general_args: GeneralArgs) -> anyhow::Result<()> {
        let _ = general_args;
        let ctx = BergContext::new(self, general_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_issue = ctx
            .client
            .issue_edit_issue(owner.as_str(), repo.as_str(), issue_id as u64, options)
            .await?;

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

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

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

        Ok(())
    }
}

async fn select_issue(ctx: &BergContext<EditIssueArgs>) -> anyhow::Result<Issue> {
    let OwnerRepo { repo, owner } = ctx.owner_repo()?;
    let issues_list = spin_until_ready(ctx.client.issue_list_issues(
        owner.as_str(),
        repo.as_str(),
        IssueListIssuesQuery::default(),
    ))
    .await?;

    if issues_list.is_empty() {
        anyhow::bail!("No issues found in this repository");
    }

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

async fn create_options(
    ctx: &BergContext<EditIssueArgs>,
    issue: &Issue,
) -> anyhow::Result<(EditIssueOption, IssueLabelsOption)> {
    let selected_update_fields = multi_fuzzy_select_with_key(
        EditableFields::VARIANTS,
        select_prompt_for("options"),
        |_| false,
        |f| f.to_string(),
    )?;

    let mut options = EditIssueOption {
        assignee: None,
        assignees: None,
        body: None,
        due_date: None,
        milestone: None,
        r#ref: None,
        state: None,
        title: None,
        unset_due_date: None,
        updated_at: None,
    };

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

    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(issue_state(ctx, issue.state).await?);
    }
    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?);
    }

    Ok((options, label_options))
}

async fn issue_assignees(
    ctx: &BergContext<EditIssueArgs>,
    current_assignees: Option<Vec<i64>>,
) -> anyhow::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?;
    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>,
) -> anyhow::Result<String> {
    ctx.editor_for(
        "a description",
        current_body
            .as_deref()
            .unwrap_or("Enter an issue description"),
    )
}

async fn issue_state(
    _ctx: &BergContext<EditIssueArgs>,
    _current_state: Option<StateType>,
) -> anyhow::Result<String> {
    let selected_state = fuzzy_select_with_key(
        &[StateType::Open, StateType::Closed],
        select_prompt_for("states"),
        |f| format!("{f:?}"),
    )?;
    Ok(format!("{selected_state:?}"))
}

async fn issue_title(
    _ctx: &BergContext<EditIssueArgs>,
    current_title: Option<String>,
) -> anyhow::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()
        .map_err(anyhow::Error::from)
}

async fn issue_labels(
    ctx: &BergContext<EditIssueArgs>,
    current_labels: Option<Vec<i64>>,
) -> anyhow::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?;

    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)
}