From 2c2b13e5fe8558c783891335aed600797f0c6817 Mon Sep 17 00:00:00 2001 From: Cyborus Date: Fri, 25 Oct 2024 11:17:33 -0400 Subject: feat: `repo migrate` --- src/repo.rs | 265 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 265 insertions(+) diff --git a/src/repo.rs b/src/repo.rs index 7c76b61..a28e843 100644 --- a/src/repo.rs +++ b/src/repo.rs @@ -324,6 +324,43 @@ pub enum RepoCommand { #[clap(long, short = 'R')] remote: Option, }, + Migrate { + /// URL of the repo to migrate + #[clap(id = "HOST/OWNER/REPO")] + repo: String, + /// Name of the new mirror + name: String, + /// Whether to mirror the repo instead of migrating it + #[clap(long, short)] + mirror: bool, + /// Whether the new migration should be private + #[clap(long, short)] + private: bool, + /// Comma-separated list of items to include. Defaults to nothing but git data. + /// + /// These are `lfs`, `wiki`, `issues`, `prs`, `milestones`, `labels`, and `releases`. + /// You can use `all` to include everything. + #[clap(long, short)] + include: Option, + /// The URL to fetch LFS files from + #[clap(long, short = 'L')] + lfs_endpoint: Option, + /// The type of Git service the original repo is on. Defaults to `git` + #[clap(long, short)] + service: Option, + /// If enabled, will read an access token in from stdin to use for fetching. + /// + /// Mutually exclusive with `--login` + #[clap(long, short)] + token: bool, + /// If enabled, will read a username and password from stdin to use for fetching. + /// + /// Mutually exclusive with `--token`. + /// + /// This is not recommended, `--token` should be used instead whenever possible. + #[clap(long, short)] + login: bool, + }, /// View a repo's info View { #[clap(id = "[HOST/]OWNER/REPO")] @@ -401,6 +438,33 @@ impl RepoCommand { .ok_or_eyre("couldn't get repo name, please specify")?; fork_repo(&api, repo, name).await? } + RepoCommand::Migrate { + repo, + name, + mirror, + private, + include, + lfs_endpoint, + service, + token, + login, + } => { + let current_repo = RepoInfo::get_current(host_name, None, None, &keys)?; + let api = keys.get_api(current_repo.host_url()).await?; + migrate_repo( + &api, + repo, + name, + mirror, + private, + include, + lfs_endpoint, + service, + token, + login, + ) + .await? + } RepoCommand::View { name, remote } => { let repo = RepoInfo::get_current(host_name, name.as_ref(), remote.as_deref(), &keys)?; @@ -546,6 +610,207 @@ async fn fork_repo(api: &Forgejo, repo: &RepoName, name: Option) -> eyre Ok(()) } +#[derive(Copy, Clone, Debug, PartialEq, Eq, clap::ValueEnum, Default)] +pub enum MigrateService { + #[default] + Git, + Github, + Gitlab, + Forgejo, + Gitea, + Gogs, + Onedev, + Gitbucket, + Codebase, +} + +impl FromStr for MigrateService { + type Err = MigrateServiceParseError; + + fn from_str(s: &str) -> Result { + match s { + "git" => Ok(Self::Git), + "github" => Ok(Self::Github), + "gitlab" => Ok(Self::Gitlab), + "forgejo" => Ok(Self::Forgejo), + "gitea" => Ok(Self::Gitea), + "gogs" => Ok(Self::Gogs), + "onedev" | "one-dev" => Ok(Self::Onedev), + "gitbucket" | "git-bucket" => Ok(Self::Gitbucket), + "codebase" => Ok(Self::Codebase), + _ => Err(MigrateServiceParseError), + } + } +} + +impl MigrateService { + fn to_api_type(self) -> forgejo_api::structs::MigrateRepoOptionsService { + use forgejo_api::structs::MigrateRepoOptionsService as Api; + use MigrateService as Cli; + match self { + Cli::Git => Api::Git, + Cli::Github => Api::Github, + Cli::Gitlab => Api::Gitlab, + Cli::Forgejo => Api::Gitea, + Cli::Gitea => Api::Gitea, + Cli::Gogs => Api::Gogs, + Cli::Onedev => Api::Onedev, + Cli::Gitbucket => Api::Gitbucket, + Cli::Codebase => Api::Codebase, + } + } +} + +#[derive(Clone, Debug)] +pub struct MigrateServiceParseError; + +impl std::error::Error for MigrateServiceParseError {} + +impl std::fmt::Display for MigrateServiceParseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("unknown service") + } +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)] +pub struct MigrateInclude { + lfs: bool, + wiki: bool, + issues: bool, + prs: bool, + milestones: bool, + labels: bool, + releases: bool, +} + +impl MigrateInclude { + /// if the selection includes anything other than LFS (which is supported by base git) + fn non_base_git(self) -> bool { + self.wiki | self.issues | self.prs | self.milestones | self.labels | self.releases + } +} + +impl FromStr for MigrateInclude { + type Err = MigrateIncludeParseError; + + fn from_str(s: &str) -> Result { + if s == "all" { + Ok(Self { + lfs: true, + wiki: true, + issues: true, + prs: true, + milestones: true, + labels: true, + releases: true, + }) + } else { + let mut out = Self::default(); + for opt in s.split(",") { + match opt { + "lfs" => out.lfs = true, + "wiki" => out.wiki = true, + "issues" => out.issues = true, + "prs" => out.prs = true, + "milestones" => out.milestones = true, + "labels" => out.labels = true, + "releases" => out.releases = true, + _ => return Err(MigrateIncludeParseError), + } + } + Ok(out) + } + } +} + +#[derive(Clone, Debug)] +pub struct MigrateIncludeParseError; + +impl std::error::Error for MigrateIncludeParseError {} + +impl std::fmt::Display for MigrateIncludeParseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("unknown include option") + } +} + +async fn migrate_repo( + api: &Forgejo, + mut repo: String, + name: String, + mirror: bool, + private: bool, + include: Option, + lfs_endpoint: Option, + service: Option, + token: bool, + login: bool, +) -> eyre::Result<()> { + let include = include.unwrap_or_default(); + let service = service.unwrap_or_default(); + + if service == MigrateService::Git && include.non_base_git() { + eyre::bail!("Migrating from a `git` service doesn't support migration items other than LFS. Please specify a different service or remove the included items"); + } + + if repo.ends_with("/") { + let _ = repo.pop(); + } + if !repo.ends_with(".git") { + repo.push_str(".git"); + } + let clone_url = + url::Url::parse(&repo).or_else(|_| url::Url::parse(&format!("https://{repo}")))?; + + let (username, password) = if login { + let username = crate::readline("Username: ").await?.trim().to_owned(); + let password = crate::readline("Password: ").await?.trim().to_owned(); + (Some(username), Some(password)) + } else { + (None, None) + }; + + let auth_token = if token { + let auth_token = crate::readline("Token: ").await?.trim().to_owned(); + Some(auth_token.trim().to_owned()) + } else { + None + }; + + let migrate_options = forgejo_api::structs::MigrateRepoOptions { + auth_password: password, + auth_username: username, + auth_token, + clone_addr: clone_url.as_str().to_owned(), + description: None, + issues: Some(include.issues), + labels: Some(include.labels), + lfs: Some(include.lfs), + lfs_endpoint: lfs_endpoint.map(|url| url.to_string()), + milestones: Some(include.milestones), + mirror: Some(mirror), + mirror_interval: None, + private: Some(private), + pull_requests: Some(include.prs), + releases: Some(include.releases), + repo_name: name, + repo_owner: None, + service: Some(service.to_api_type()), + uid: None, + wiki: Some(include.wiki), + }; + + println!("Migrating..."); + let new_repo = api.repo_migrate(migrate_options).await?; + let new_repo_url = new_repo + .html_url + .as_ref() + .ok_or_eyre("new repo doesnt have url")?; + println!("Done! View online at {new_repo_url}"); + + Ok(()) +} + async fn view_repo(api: &Forgejo, repo: &RepoName) -> eyre::Result<()> { let repo = api.repo_get(repo.owner(), repo.name()).await?; -- cgit v1.2.3