codeberg-cli 0.4.9

CLI Tool for codeberg similar to gh and glab
Documentation
use crate::actions::text_manipulation::{input_prompt_for, select_prompt_for};
use crate::actions::GeneralArgs;
use crate::render::option::option_display;
use crate::render::ui::{
    fuzzy_select_with_key, fuzzy_select_with_key_with_default, multi_fuzzy_select_with_key,
};
use crate::types::context::BergContext;
use crate::types::git::OwnerRepo;
use anyhow::Context;
use clap::Parser;
use forgejo_api::structs::{
    CreatePullRequestOption, IssueGetMilestonesListQuery, IssueListLabelsQuery,
    RepoListBranchesQuery,
};
use strum::{Display, VariantArray};

/// Create a pull request
#[derive(Parser, Debug)]
pub struct CreatePullRequestArgs {
    /// Comma-delimited list of assignee names
    #[arg(short, long, value_name = "ASSIGNEE,...", value_delimiter = ',')]
    pub assignees: Option<Vec<String>>,

    /// Target branch for the pull request
    #[arg(short, long)]
    pub target_branch: Option<String>,

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

    /// Source branch of the pull request
    #[arg(short, long)]
    pub source_branch: Option<String>,

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

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

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

    /// Interactive mode which guides the user through optional argument options
    #[arg(short, long)]
    pub interactive: bool,
}

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

impl CreatePullRequestArgs {
    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 options = create_options(&ctx).await?;
        let pull_request = ctx
            .client
            .repo_create_pull_request(owner.as_str(), repo.as_str(), options)
            .await?;
        tracing::debug!("{pull_request:?}");
        Ok(())
    }
}

async fn create_options(
    ctx: &BergContext<CreatePullRequestArgs>,
) -> anyhow::Result<CreatePullRequestOption> {
    let title = match ctx.args.title.as_ref() {
        Some(title) => title.clone(),
        None => inquire::Text::new(input_prompt_for("Pull Request Title").as_str()).prompt()?,
    };

    let target_branch = match ctx.args.target_branch.as_ref() {
        Some(branch) => branch.clone(),
        None => {
            select_branch(
                ctx,
                "target branch into which changes are merged",
                vec!["main", "master"],
                None,
            )
            .await?
        }
    };

    let source_branch = match ctx.args.source_branch.as_ref() {
        Some(branch) => branch.clone(),
        None => {
            let current_checkout = get_current_checkout()?;
            select_branch(
                ctx,
                "source branch containing the changes",
                vec![current_checkout.as_str()],
                Some(target_branch.as_str()),
            )
            .await?
        }
    };

    let mut options = CreatePullRequestOption {
        title: Some(title),
        base: Some(target_branch),
        head: Some(source_branch),
        assignee: None,
        assignees: None,
        body: None,
        due_date: None,
        labels: None,
        milestone: None,
    };

    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(),
    )?;

    options.body.replace(pr_body(ctx)?);
    {
        use CreatableFields::*;
        options.labels = pr_labels(ctx, chosen_optionals.contains(&&Labels)).await?;
        options.assignees = pr_assignees(ctx, chosen_optionals.contains(&&Assignees)).await?;
        options.milestone = pr_milestone(ctx, chosen_optionals.contains(&&Milestone)).await?;
    }

    Ok(options)
}

fn pr_body(ctx: &BergContext<CreatePullRequestArgs>) -> anyhow::Result<String> {
    let body = match ctx.args.body.as_ref() {
        Some(body) => body.clone(),
        None => ctx.editor_for("a description", "Enter a pull request description")?,
    };
    Ok(body)
}

async fn pr_labels(
    ctx: &BergContext<CreatePullRequestArgs>,
    interactive: bool,
) -> anyhow::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?;

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

            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 pr_assignees(
    ctx: &BergContext<CreatePullRequestArgs>,
    interactive: bool,
) -> anyhow::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?;
    let assignees = match &ctx.args.assignees {
        Some(assignees_names) => Some(assignees_names.clone()),
        None => {
            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 pr_milestone(
    ctx: &BergContext<CreatePullRequestArgs>,
    interactive: bool,
) -> anyhow::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?;

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

async fn select_branch(
    ctx: &BergContext<CreatePullRequestArgs>,
    prompt_text: &str,
    default_branch_names: Vec<&str>,
    filter_branch: Option<&str>,
) -> anyhow::Result<String> {
    let OwnerRepo { repo, owner } = ctx.owner_repo()?;
    let branches = ctx
        .client
        .repo_list_branches(
            owner.as_str(),
            repo.as_str(),
            RepoListBranchesQuery::default(),
        )
        .await?
        .into_iter()
        .filter(|branch| {
            branch
                .name
                .as_ref()
                .is_some_and(|name| filter_branch.is_none_or(|filter| name != filter))
        })
        .collect::<Vec<_>>();

    if branches.is_empty() {
        if filter_branch.is_some() {
            anyhow::bail!("No remote branches except {filter_branch:?} found. Maybe the branch you want to merge doesn't exist on remote yet?");
        } else {
            anyhow::bail!("No remote branches found.");
        }
    }

    let default_index = default_branch_names.iter().find_map(|&default_name| {
        branches.iter().position(|branch| {
            branch
                .name
                .as_ref()
                .is_some_and(|name| name.as_str() == default_name)
        })
    });

    fuzzy_select_with_key_with_default(
        &branches,
        select_prompt_for(prompt_text),
        |b| option_display(&b.name),
        default_index,
    )
    .map(|branch| option_display(&branch.name))
}

fn get_current_checkout() -> anyhow::Result<String> {
    let output = std::process::Command::new("git")
        .arg("branch")
        .arg("--show-current")
        .output()?;
    String::from_utf8(output.stdout)
        .map(|base| base.trim().to_owned())
        .map_err(anyhow::Error::from)
}