Skip to main content

codeberg_cli/actions/repo/
create.rs

1use crate::actions::GlobalArgs;
2use crate::actions::repo::clone::RepoCloneArgs;
3use crate::actions::text_manipulation::{input_prompt_for, select_prompt_for};
4use crate::render::json::JsonToStdout;
5use crate::render::ui::{fuzzy_select_with_key, multi_fuzzy_select_with_key};
6use crate::types::api::privacy_type::Privacy;
7use crate::types::context::BergContext;
8use crate::types::git::{Git, MaybeOwnerRepo, OwnerRepo};
9use crate::types::output::OutputMode;
10use clap::Parser;
11use forgejo_api::structs::CreateRepoOption;
12use forgejo_api::{ApiError, ApiErrorKind, ForgejoError};
13use miette::{Context, IntoDiagnostic};
14use strum::{Display, VariantArray};
15
16/// Create a new repository
17#[derive(Parser, Debug)]
18pub struct RepoCreateArgs {
19    /// Main branch to init repository with (usually "main")
20    #[arg(id = "default-branch", long)]
21    pub default_branch: Option<String>,
22
23    /// Repository description
24    #[arg(short, long)]
25    pub description: Option<String>,
26
27    /// Organization name
28    #[arg(long)]
29    pub org: Option<String>,
30
31    /// Repository name
32    #[arg(short, long)]
33    pub name: Option<String>,
34
35    /// Repository visibility
36    #[arg(short, long, value_enum, value_name = "VISIBILITY")]
37    pub private: Option<Privacy>,
38
39    /// Whether or not to clone the repo after creating it remotely
40    #[arg(long)]
41    pub and_clone: bool,
42
43    /// Whether or not to clone the repo after creating it remotely
44    #[arg(long)]
45    pub and_push_from: Option<std::path::PathBuf>,
46}
47
48#[derive(Display, PartialEq, Eq, VariantArray)]
49enum CreatableFields {
50    DefaultBranch,
51    Description,
52    Private,
53}
54
55impl RepoCreateArgs {
56    pub async fn run(self, global_args: GlobalArgs) -> miette::Result<()> {
57        let ctx = BergContext::new(self, global_args.clone()).await?;
58
59        if ctx.args.and_clone && ctx.args.and_push_from.is_some() {
60            miette::bail!(
61                help = "Please choose either `--and-clone` OR `--and-push-from`",
62                "Detected both `--and-clone` AND `--and-push-from` which are mutually exclusive!"
63            );
64        }
65
66        let options = create_options(&ctx).await?;
67        let repo = options.name.clone();
68        let repository = if let Some(org) = ctx.args.org.as_ref() {
69            ctx.client.create_org_repo(org, options).await
70        } else {
71            ctx.client.create_current_user_repo(options).await
72        }
73        .map_err(|e| match e {
74            ForgejoError::ApiError(ApiError {
75                kind: ApiErrorKind::Other(code),
76                ..
77            }) if code.as_u16() == 409 => {
78                miette::miette!("Repository with the same name already exists!, got: {e}")
79            }
80            _ => miette::miette!("{e}"),
81        })?;
82        match ctx.global_args.output_mode {
83            OutputMode::Pretty => {
84                tracing::debug!("{repository:?}");
85            }
86            OutputMode::Json => repository.print_json()?,
87        }
88
89        // optionally clone the repository if user wishes so
90        if ctx.args.and_clone {
91            let user = ctx
92                .args
93                .org
94                .or(ctx.client.user_get_current().await.into_diagnostic()?.login)
95                .wrap_err_with(|| {
96                    miette::miette!(
97                        help = "Check your user settings",
98                        help = "Check your forgejo instance or contact its admins",
99                        "Currently logged in user has no valid username, aborting!"
100                    )
101                })?;
102            let owner_repo = MaybeOwnerRepo::ExplicitOwner(OwnerRepo { owner: user, repo });
103            let ssh_clone = RepoCloneArgs {
104                owner_and_repo: owner_repo.clone(),
105                use_ssh: true,
106                destination: None,
107            }
108            .run(global_args.clone())
109            .await
110            .context("Cloning with ssh failed ... trying https as fallback");
111
112            if ssh_clone.is_err() {
113                RepoCloneArgs {
114                    owner_and_repo: owner_repo,
115                    use_ssh: false,
116                    destination: None,
117                }
118                .run(global_args)
119                .await
120                .wrap_err(miette::miette!(
121                    help = "This shouldn't happen. Please open an issue!",
122                    "Cloning via ssh and via https both failed!"
123                ))?;
124            }
125        }
126
127        if let Some(repo_path) = ctx.args.and_push_from {
128            let url = repository
129                .ssh_url
130                .context("Created repository is missing URL!")?;
131            let git = Git::new(repo_path);
132            let remote_name = if !ctx.global_args.non_interactive {
133                let help_msg = [
134                    "Please choose a remote that doesn't exist already!",
135                    "Otherwise we'll try both 'origin' and 'origin-new'!",
136                ]
137                .join("\n");
138                Some(
139                    inquire::Text::new(input_prompt_for("Remote Name").as_str())
140                        .with_help_message(help_msg.as_str())
141                        .prompt()
142                        .into_diagnostic()?,
143                )
144            } else {
145                None
146            };
147            git.push(remote_name, &url)
148                .context("Failed to push git repository after remote creation")?;
149            println!("Successfully pushed to {url}", url = url.as_str());
150        }
151        Ok(())
152    }
153}
154
155async fn create_options(ctx: &BergContext<RepoCreateArgs>) -> miette::Result<CreateRepoOption> {
156    let name = match ctx.args.name.as_ref() {
157        Some(name) => name.clone(),
158        None => inquire::Text::new(input_prompt_for("New Repository Name").as_str())
159            .prompt()
160            .into_diagnostic()?,
161    };
162
163    let mut options = CreateRepoOption {
164        name,
165        auto_init: None,
166        default_branch: ctx.args.default_branch.clone(),
167        description: ctx.args.description.clone(),
168        gitignores: None,
169        issue_labels: None,
170        license: None,
171        private: ctx.args.private.map(|p| match p {
172            Privacy::Private => true,
173            Privacy::Public => false,
174        }),
175        readme: None,
176        template: None,
177        trust_model: None,
178        object_format_name: None,
179    };
180
181    let optional_data = {
182        use CreatableFields::*;
183        [
184            (DefaultBranch, ctx.args.default_branch.is_none()),
185            (Description, ctx.args.description.is_none()),
186            (Private, ctx.args.private.is_none()),
187        ]
188        .into_iter()
189        .filter_map(|(name, missing)| missing.then_some(name))
190        .collect::<Vec<_>>()
191    };
192
193    if !optional_data.is_empty() && !ctx.global_args.non_interactive {
194        let chosen_optionals = multi_fuzzy_select_with_key(
195            &optional_data,
196            "Choose optional properties",
197            |_| false,
198            |o| o.to_string(),
199        )?;
200
201        {
202            use CreatableFields::*;
203            options.default_branch.replace(repo_default_branch(
204                ctx,
205                chosen_optionals.contains(&&DefaultBranch),
206            )?);
207            options.private = repo_private(ctx, chosen_optionals.contains(&&Private)).await?;
208            options.description =
209                repo_description(ctx, chosen_optionals.contains(&&Description)).await?;
210        }
211    }
212
213    Ok(options)
214}
215
216fn repo_default_branch(
217    ctx: &BergContext<RepoCreateArgs>,
218    interactive: bool,
219) -> miette::Result<String> {
220    let branch = match ctx.args.default_branch.as_ref() {
221        Some(branch) => branch.clone(),
222        None => {
223            if !interactive {
224                return Ok(String::from("main"));
225            }
226            inquire::Text::new(input_prompt_for("Default Branch Name").as_str())
227                .prompt()
228                .into_diagnostic()?
229        }
230    };
231    Ok(branch)
232}
233
234async fn repo_private(
235    ctx: &BergContext<RepoCreateArgs>,
236    interactive: bool,
237) -> miette::Result<Option<bool>> {
238    let privacy = match ctx.args.private {
239        Some(privacy) => match privacy {
240            Privacy::Private => true,
241            Privacy::Public => false,
242        },
243        None => {
244            if !interactive {
245                return Ok(None);
246            }
247            fuzzy_select_with_key(
248                &[true, false],
249                select_prompt_for("repo privacy"),
250                |private| {
251                    if *private {
252                        String::from("Private")
253                    } else {
254                        String::from("Public")
255                    }
256                },
257            )
258            .copied()?
259        }
260    };
261    Ok(Some(privacy))
262}
263
264async fn repo_description(
265    ctx: &BergContext<RepoCreateArgs>,
266    interactive: bool,
267) -> miette::Result<Option<String>> {
268    let description = match ctx.args.description.as_ref() {
269        Some(desc) => desc.clone(),
270        None => {
271            if !interactive {
272                return Ok(None);
273            }
274            ctx.editor_for("a description", "Enter Repository description")?
275        }
276    };
277    Ok(Some(description))
278}