codeberg-cli 0.5.5

CLI Tool for codeberg similar to gh and glab
Documentation
use crate::actions::GlobalArgs;
use crate::actions::repo::clone::RepoCloneArgs;
use crate::actions::text_manipulation::{input_prompt_for, select_prompt_for};
use crate::render::json::JsonToStdout;
use crate::render::ui::{fuzzy_select_with_key, multi_fuzzy_select_with_key};
use crate::types::api::privacy_type::Privacy;
use crate::types::context::BergContext;
use crate::types::git::{Git, MaybeOwnerRepo, OwnerRepo};
use crate::types::output::OutputMode;
use clap::Parser;
use forgejo_api::structs::CreateRepoOption;
use forgejo_api::{ApiError, ApiErrorKind, ForgejoError};
use miette::{Context, IntoDiagnostic};
use strum::{Display, VariantArray};

/// Create a new repository
#[derive(Parser, Debug)]
pub struct RepoCreateArgs {
    /// Main branch to init repository with (usually "main")
    #[arg(id = "default-branch", long)]
    pub default_branch: Option<String>,

    /// Repository description
    #[arg(short, long)]
    pub description: Option<String>,

    /// Organization name
    #[arg(long)]
    pub org: Option<String>,

    /// Repository name
    #[arg(short, long)]
    pub name: Option<String>,

    /// Repository visibility
    #[arg(short, long, value_enum, value_name = "VISIBILITY")]
    pub private: Option<Privacy>,

    /// Whether or not to clone the repo after creating it remotely
    #[arg(long)]
    pub and_clone: bool,

    /// Whether or not to clone the repo after creating it remotely
    #[arg(long)]
    pub and_push_from: Option<std::path::PathBuf>,
}

#[derive(Display, PartialEq, Eq, VariantArray)]
enum CreatableFields {
    DefaultBranch,
    Description,
    Private,
}

impl RepoCreateArgs {
    pub async fn run(self, global_args: GlobalArgs) -> miette::Result<()> {
        let ctx = BergContext::new(self, global_args.clone()).await?;

        if ctx.args.and_clone && ctx.args.and_push_from.is_some() {
            miette::bail!(
                help = "Please choose either `--and-clone` OR `--and-push-from`",
                "Detected both `--and-clone` AND `--and-push-from` which are mutually exclusive!"
            );
        }

        let options = create_options(&ctx).await?;
        let repo = options.name.clone();
        let repository = if let Some(org) = ctx.args.org.as_ref() {
            ctx.client.create_org_repo(org, options).await
        } else {
            ctx.client.create_current_user_repo(options).await
        }
        .map_err(|e| match e {
            ForgejoError::ApiError(ApiError {
                kind: ApiErrorKind::Other(code),
                ..
            }) if code.as_u16() == 409 => {
                miette::miette!("Repository with the same name already exists!, got: {e}")
            }
            _ => miette::miette!("{e}"),
        })?;
        match ctx.global_args.output_mode {
            OutputMode::Pretty => {
                tracing::debug!("{repository:?}");
            }
            OutputMode::Json => repository.print_json()?,
        }

        // optionally clone the repository if user wishes so
        if ctx.args.and_clone {
            let user = ctx
                .args
                .org
                .or(ctx.client.user_get_current().await.into_diagnostic()?.login)
                .wrap_err_with(|| {
                    miette::miette!(
                        help = "Check your user settings",
                        help = "Check your forgejo instance or contact its admins",
                        "Currently logged in user has no valid username, aborting!"
                    )
                })?;
            let owner_repo = MaybeOwnerRepo::ExplicitOwner(OwnerRepo { owner: user, repo });
            let ssh_clone = RepoCloneArgs {
                owner_and_repo: owner_repo.clone(),
                use_ssh: true,
                destination: None,
            }
            .run(global_args.clone())
            .await
            .context("Cloning with ssh failed ... trying https as fallback");

            if ssh_clone.is_err() {
                RepoCloneArgs {
                    owner_and_repo: owner_repo,
                    use_ssh: false,
                    destination: None,
                }
                .run(global_args)
                .await
                .wrap_err(miette::miette!(
                    help = "This shouldn't happen. Please open an issue!",
                    "Cloning via ssh and via https both failed!"
                ))?;
            }
        }

        if let Some(repo_path) = ctx.args.and_push_from {
            let url = repository
                .ssh_url
                .context("Created repository is missing URL!")?;
            let git = Git::new(repo_path);
            let remote_name = if !ctx.global_args.non_interactive {
                let help_msg = [
                    "Please choose a remote that doesn't exist already!",
                    "Otherwise we'll try both 'origin' and 'origin-new'!",
                ]
                .join("\n");
                Some(
                    inquire::Text::new(input_prompt_for("Remote Name").as_str())
                        .with_help_message(help_msg.as_str())
                        .prompt()
                        .into_diagnostic()?,
                )
            } else {
                None
            };
            git.push(remote_name, &url)
                .context("Failed to push git repository after remote creation")?;
            println!("Successfully pushed to {url}", url = url.as_str());
        }
        Ok(())
    }
}

async fn create_options(ctx: &BergContext<RepoCreateArgs>) -> miette::Result<CreateRepoOption> {
    let name = match ctx.args.name.as_ref() {
        Some(name) => name.clone(),
        None => inquire::Text::new(input_prompt_for("New Repository Name").as_str())
            .prompt()
            .into_diagnostic()?,
    };

    let mut options = CreateRepoOption {
        name,
        auto_init: None,
        default_branch: ctx.args.default_branch.clone(),
        description: ctx.args.description.clone(),
        gitignores: None,
        issue_labels: None,
        license: None,
        private: ctx.args.private.map(|p| match p {
            Privacy::Private => true,
            Privacy::Public => false,
        }),
        readme: None,
        template: None,
        trust_model: None,
        object_format_name: None,
    };

    let optional_data = {
        use CreatableFields::*;
        [
            (DefaultBranch, ctx.args.default_branch.is_none()),
            (Description, ctx.args.description.is_none()),
            (Private, ctx.args.private.is_none()),
        ]
        .into_iter()
        .filter_map(|(name, missing)| missing.then_some(name))
        .collect::<Vec<_>>()
    };

    if !optional_data.is_empty() && !ctx.global_args.non_interactive {
        let chosen_optionals = multi_fuzzy_select_with_key(
            &optional_data,
            "Choose optional properties",
            |_| false,
            |o| o.to_string(),
        )?;

        {
            use CreatableFields::*;
            options.default_branch.replace(repo_default_branch(
                ctx,
                chosen_optionals.contains(&&DefaultBranch),
            )?);
            options.private = repo_private(ctx, chosen_optionals.contains(&&Private)).await?;
            options.description =
                repo_description(ctx, chosen_optionals.contains(&&Description)).await?;
        }
    }

    Ok(options)
}

fn repo_default_branch(
    ctx: &BergContext<RepoCreateArgs>,
    interactive: bool,
) -> miette::Result<String> {
    let branch = match ctx.args.default_branch.as_ref() {
        Some(branch) => branch.clone(),
        None => {
            if !interactive {
                return Ok(String::from("main"));
            }
            inquire::Text::new(input_prompt_for("Default Branch Name").as_str())
                .prompt()
                .into_diagnostic()?
        }
    };
    Ok(branch)
}

async fn repo_private(
    ctx: &BergContext<RepoCreateArgs>,
    interactive: bool,
) -> miette::Result<Option<bool>> {
    let privacy = match ctx.args.private {
        Some(privacy) => match privacy {
            Privacy::Private => true,
            Privacy::Public => false,
        },
        None => {
            if !interactive {
                return Ok(None);
            }
            fuzzy_select_with_key(
                &[true, false],
                select_prompt_for("repo privacy"),
                |private| {
                    if *private {
                        String::from("Private")
                    } else {
                        String::from("Public")
                    }
                },
            )
            .copied()?
        }
    };
    Ok(Some(privacy))
}

async fn repo_description(
    ctx: &BergContext<RepoCreateArgs>,
    interactive: bool,
) -> miette::Result<Option<String>> {
    let description = match ctx.args.description.as_ref() {
        Some(desc) => desc.clone(),
        None => {
            if !interactive {
                return Ok(None);
            }
            ctx.editor_for("a description", "Enter Repository description")?
        }
    };
    Ok(Some(description))
}