8 Commits
v0.2 ... v0.5

Author SHA1 Message Date
5fc739be23 Allow giving API key via env var or CLI args. Gemini 3.0
All checks were successful
Cargo Build & Test / Rust project - latest (1.90) (push) Successful in 2m15s
Prompt
```
Nice! Ok, last change. Can you allow specifying the API key via a CLI
arg, and via an environment variable? Also, have the env var use the
same naming style as for "GITEA_MIRROR_CONFIG_FILEPATH".
```
2025-11-18 18:31:42 -05:00
8f142e07ba Add a flag to not run deletes, meaning non destructive. Gemini 3.0
Prompt;
```
Great! Now can you add a new flag which says to not delete projects.
Basically, I want an option to run the tool in a non destructive
fashion.
```
2025-11-18 18:21:40 -05:00
89d273c38e Add a terraform style plan and allow deleting projects (Gemini 3.0)
Prompt;
```
Can you add a feature such that the tool will list all projects to add
(including renames), and after asking the user for a confirmation to the
deletion? Also add a flag for "no-confirm", which applies the plan
without asking for confirmation. This is basically a dry-run but the
user implicitly saying no for if they want to continue.

Can you also add a feature that the tool will also query gitea if any
projects exist on gitea when they aren't in the toml file, and after
confirming with the user of the plan (which projects to add and remove),
will delete those projects on gitea? Lastly, for the dry run, do the
query of files to be deleted but don't actually delete the files.
```

```
To the execution plan output where you say what projects will be added
and deleted, can you also add what projects  already exist and therefore
have no action needed?
```
2025-11-18 18:16:12 -05:00
ae347d7506 Add repo_owner for where the migrated repos should go to
All checks were successful
Cargo Build & Test / Rust project - latest (1.90) (push) Successful in 2m11s
Generated with Claude 4.5 Sonnet (Gemini 2.5 Pro was stubborn about
wanting to refactor the world even when it was unrelated). Prompt was;
```
Given the attached rust code, and example toml configuration file;
... SNIP ...

Can you add the ability to specify the owner of the migrated repo, such
that if not provided then to use the user who owns the API key, or if
provided then to use said owner? I think gitea refers to that as the
"repo_owner" in it's swagger based API docs? Note, this would be one
configuration for all the repos in the config toml file, not on a
per-repo basis.
```

which didn't work since it tried to fetch a uid which doesn't exist for
organizations, so lets prompt it to fix that and give it a helping hand;
```
Ah shoot, if I am using a migration to a new organization which the
user who owns the API key has permissions to modify, then I am getting
a 401 return code. Did you assume the target will always be a user
rather than also being an organization? Also, keep in mind, I think
giteas migration API wants a user string, rather than a user ID, if
that's the case then I think we can remove the entire
`get_user_id_by_username()` function?
```
2025-10-06 19:17:28 -04:00
fdb7cf7a4a Some human touches (make clearer this' vibe coded, better env vars, etc)
All checks were successful
Cargo Build & Test / Rust project - latest (1.90) (push) Successful in 1m51s
2025-09-22 20:40:17 -04:00
3497cbaa6e Upload example config toml 2025-09-22 20:33:36 -04:00
129d67bc8b Don't use the same API key for other organizations we are pulling from
```EOF
For the organizations list, I am trying use my test instance, but getting the following in the logs;
```
2025-09-23T00:12:38.638052Z  INFO gitea_mirror: Fetching repositories from organization: https://gitea.hak8or.com/mirrors
2025-09-23T00:12:38.638081Z  INFO fetch_org_repos{org_url="https://gitea.hak8or.com/mirrors"}: gitea_mirror: Querying API endpoint: https://gitea.hak8or.com/api/v1/users/mirrors/repos
2025-09-23T00:12:38.653694Z ERROR gitea_mirror: Failed to fetch repos from https://gitea.hak8or.com/mirrors: HTTP status client error (401 Unauthorized) for url (https://gitea.hak8or.com/api/v1/users/mirrors/repos?page=1)
2025-09-23T00:12:38.653713Z  INFO gitea_mirror: Gitea mirror process completed.
```

I don't have a user with that key for the instance. Can you add the ability to provide an api key to each organization entry in the toml config? At the same time, is it possible to get a list of all repos from an organization without needing to use an api key? If so, when no api key is provided, can you use that?
```EOF
2025-09-22 20:29:14 -04:00
f732535db2 Re-create this with the canvas option in Gemini 2.5 Pro web chat
```EOF
Create a very minimal and simple tool written in rust which takes in a list of git URLs, and using the gitea api checks if the remote is already mirrored, and if not, then create a repo migration to gitea. I want to basically create a script which can be used to ensure a list of git repos are mirrord to a gitea server.

 The script should take in some command line arguments for;
  - an option to do a dry run, meaning do the check if the repo has to be mirrord, but do not initiate the actual migration
 - path to a TOML configuration file (also can be supplied via an ENV variable)

 The configuration file would have the following information;
   - an API key to be used when talking to the gitea instance we are migrating to
  - the url of the above gitea instance
  - a list of git URLs including an optional rename of the repo name
  - a list of URLs of another git server (gitea, if the API is the same then github, gitlab, etc) that includes the organization name or username. You would clone all repos under that organization/username. For example "https://github.com/hak8or" would be all repos owned by hak8or.

Example toml file;
```
gitea_url = "https://gitmirror.hak8or.com"

api_key = "api_key_goes_here"

repos = [
	{ url = "https://gitea.hak8or.com/hak8or/gitea_mirror.git" },
	{ rename = "cool_rename", url = "https://gitea.hak8or.com/hak8or/gitea_mirror.git" },
	{ rename = "cool_another_rename", url = "https://gitea.hak8or.com/hak8or/gitea_mirror.git" },
	{ rename = "rusty_rust", url = "https://github.com/rust-lang/rust.git" },
]
```

Ensure the script is as minimal as possible, do not use libraries if you can avoid them (except clap for CLI arguments, tracing for logging, actix for async and web interactions, reqwest for actual queries, and serde_json for json, or whatever else is commonly used in rust). I will be invoking this tool with a systemd timer.
```EOF
2025-09-22 20:28:35 -04:00
4 changed files with 397 additions and 197 deletions

20
Cargo.lock generated
View File

@@ -67,12 +67,6 @@ dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "anyhow"
version = "1.0.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]]
name = "atomic-waker"
version = "1.1.2"
@@ -352,7 +346,6 @@ checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f"
name = "gitea_mirror"
version = "0.1.0"
dependencies = [
"anyhow",
"clap",
"reqwest",
"serde",
@@ -361,7 +354,6 @@ dependencies = [
"toml",
"tracing",
"tracing-subscriber",
"url",
]
[[package]]
@@ -1091,9 +1083,9 @@ dependencies = [
[[package]]
name = "serde"
version = "1.0.225"
version = "1.0.226"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd6c24dee235d0da097043389623fb913daddf92c76e9f5a1db88607a0bcbd1d"
checksum = "0dca6411025b24b60bfa7ec1fe1f8e710ac09782dca409ee8237ba74b51295fd"
dependencies = [
"serde_core",
"serde_derive",
@@ -1101,18 +1093,18 @@ dependencies = [
[[package]]
name = "serde_core"
version = "1.0.225"
version = "1.0.226"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "659356f9a0cb1e529b24c01e43ad2bdf520ec4ceaf83047b83ddcc2251f96383"
checksum = "ba2ba63999edb9dac981fb34b3e5c0d111a69b0924e253ed29d83f7c99e966a4"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.225"
version = "1.0.226"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ea936adf78b1f766949a4977b91d2f5595825bd6ec079aa9543ad2685fc4516"
checksum = "8db53ae22f34573731bafa1db20f04027b2d25e02d8205921b569171699cdb33"
dependencies = [
"proc-macro2",
"quote",

View File

@@ -4,13 +4,11 @@ version = "0.1.0"
edition = "2024"
[dependencies]
anyhow = "1.0.100"
clap = { version = "4.5", features = ["derive", "env"] }
reqwest = { version = "0.12.23", features = ["json"] }
clap = { version = "4.0", features = ["derive", "env"] }
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.12", features = ["json"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tokio = { version = "1.35", features = ["full"] }
toml = "0.9.7"
toml = "0.9"
tracing = "0.1"
tracing-subscriber = "0.3"
url = "2.5.7"

21
example.toml Normal file
View File

@@ -0,0 +1,21 @@
# The base URL of your Gitea instance
gitea_url = "https://gitmirror.hak8or.com"
# Your Gitea API key (generate one from User Settings -> Applications)
api_key = "API_KEY_GOES_HERE"
# Optional: specify the owner username for all migrated repos
# If not specified, uses the user who owns the API key
repo_owner = "mirror_org"
# A list of remote git repositories to mirror.
repos = [
{ url = "https://gitea.hak8or.com/hak8or/gitea_mirror.git" },
{ rename = "cool_rename", url = "https://gitea.hak8or.com/hak8or/gitea_mirror.git" },
{ rename = "cool_another_rename", url = "https://gitea.hak8or.com/hak8or/gitea_mirror.git" },
{ url = "https://github.com/justcallmekoko/ESP32Marauder" }
]
organizations = [
{ url = "https://gitea.hak8or.com/mirrors" },
]

View File

@@ -1,229 +1,418 @@
use anyhow::{Context, Result};
use clap::Parser;
use reqwest::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE, USER_AGENT};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use serde::Deserialize;
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::PathBuf;
use tracing::{error, info, warn};
use url::Url;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use tracing::{Level, error, info, instrument, warn};
use tracing_subscriber;
// --- Structs (Unchanged) ---
#[derive(Parser, Debug)]
#[command(
author,
version,
about = "A simple tool to ensure git repositories are mirrored to Gitea."
)]
struct Cli {
#[arg(short, long, env = "GITEA_MIRROR_CONFIG")]
#[command(name = "gitea-mirror")]
#[command(about = "Syncs Git repositories to Gitea based on a TOML config.")]
struct Args {
/// Path to the TOML configuration file.
#[clap(short, long, value_parser, env = "GITEA_MIRROR_CONFIG_FILEPATH")]
config: PathBuf,
#[arg(long)]
/// Gitea API Key.
#[clap(short, long, env = "GITEA_MIRROR_API_KEY")]
api_key: Option<String>,
/// Calculate the plan but do not execute API calls.
#[clap(short, long, default_value_t = false)]
dry_run: bool,
/// Skip the interactive confirmation prompt.
#[clap(long, default_value_t = false)]
no_confirm: bool,
/// Do not delete repositories from Gitea.
#[clap(long, default_value_t = false)]
no_delete: bool,
}
#[derive(Deserialize, Debug)]
struct RepoToMirror {
#[derive(Deserialize, Debug, Clone)]
struct RepoConfig {
url: String,
rename: Option<String>,
}
#[derive(Deserialize, Debug, Clone)]
struct OrgConfig {
url: String,
api_key: Option<String>,
}
#[derive(Deserialize, Debug)]
struct Config {
gitea_url: String,
api_key: String,
repos: Vec<RepoToMirror>,
api_key: Option<String>,
repos: Option<Vec<RepoConfig>>,
organizations: Option<Vec<OrgConfig>>,
repo_owner: Option<String>,
}
#[derive(serde::Serialize, Debug)]
struct MigrateRepoPayload<'a> {
clone_addr: &'a str,
repo_name: &'a str,
repo_owner: &'a str,
mirror: bool,
private: bool,
description: &'a str,
}
// --- Gitea API Structs (Corrected) ---
#[derive(Deserialize, Debug)]
struct GiteaUser {
id: i64,
login: String,
}
// **MODIFIED**: This struct now includes `name` and the correct `mirror_url` field.
#[derive(Deserialize, Debug)]
struct GiteaRepo {
name: String,
mirror: bool,
mirror_url: Option<String>, // The original source URL of the mirror
}
#[derive(Serialize, Debug)]
struct MigrationRequest<'a> {
clone_addr: &'a str,
uid: i64,
repo_name: &'a str,
mirror: bool,
private: bool,
description: String,
}
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt::init();
let cli = Cli::parse();
async fn main() -> Result<(), Box<dyn std::error::Error>> {
tracing_subscriber::fmt().with_max_level(Level::INFO).init();
let args = Args::parse();
let config = load_config(&args.config)?;
let http_client = reqwest::Client::new();
let config_content = fs::read_to_string(&cli.config)
.with_context(|| format!("Failed to read config file at {:?}", cli.config))?;
let config: Config =
toml::from_str(&config_content).context("Failed to parse TOML configuration")?;
// Resolve API Key: CLI/Env > Config File
let final_api_key = args
.api_key
.or(config.api_key.clone())
.ok_or("API Key must be provided via --api-key, GITEA_MIRROR_API_KEY, or config file.")?;
if cli.dry_run {
info!("Performing a dry run. No migrations will be created.");
// 1. Determine Target Owner
let owner_name = if let Some(owner) = &config.repo_owner {
owner.clone()
} else {
get_authenticated_username(&http_client, &config.gitea_url, &final_api_key).await?
};
info!("Target Owner: {}", owner_name);
// 2. Build 'Desired' State (Map<RepoName, CloneUrl>)
info!("Resolving desired state from configuration...");
let mut desired_repos: HashMap<String, String> = HashMap::new();
// 2a. Static Repos
if let Some(repos) = &config.repos {
for r in repos {
let name = r
.rename
.as_deref()
.or_else(|| extract_repo_name(&r.url))
.ok_or_else(|| format!("Invalid URL: {}", r.url))?;
desired_repos.insert(name.to_string(), r.url.clone());
}
}
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(ACCEPT, "application/json".parse()?);
headers.insert(CONTENT_TYPE, "application/json".parse()?);
headers.insert(USER_AGENT, "gitea-mirror-tool/0.1.0".parse()?);
headers.insert(AUTHORIZATION, format!("token {}", config.api_key).parse()?);
let client = reqwest::Client::builder()
.default_headers(headers)
.build()?;
// 2b. Organization Repos
if let Some(orgs) = &config.organizations {
for org in orgs {
info!("Fetching repos from source: {}", org.url);
let urls =
fetch_external_org_repos(&http_client, &org.url, org.api_key.as_deref()).await?;
for url in urls {
if let Some(name) = extract_repo_name(&url) {
desired_repos.insert(name.to_string(), url);
}
}
}
}
info!("🔗 Connecting to Gitea instance at {}", config.gitea_url);
// 3. Build 'Current' State (Set<RepoName>)
info!("Fetching existing repositories from Gitea ({})", owner_name);
let existing_repos =
fetch_all_target_repos(&http_client, &config.gitea_url, &final_api_key, &owner_name)
.await?;
let existing_set: HashSet<String> = existing_repos.into_iter().collect();
let user_url = format!("{}/api/v1/user", config.gitea_url);
let user = client
.get(&user_url)
// 4. Calculate Diff
let mut to_add: Vec<(String, String)> = desired_repos
.iter()
.filter(|(name, _)| !existing_set.contains(*name))
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
// Sort for consistent output
to_add.sort_by(|a, b| a.0.cmp(&b.0));
let mut to_delete: Vec<String> = existing_set
.iter()
.filter(|name| !desired_repos.contains_key(*name))
.cloned()
.collect();
to_delete.sort();
let mut to_keep: Vec<String> = desired_repos
.keys()
.filter(|name| existing_set.contains(*name))
.cloned()
.collect();
to_keep.sort();
// 5. Present Plan
println!("\n--- Execution Plan ---");
for name in &to_keep {
println!(" [=] KEEP: {}", name);
}
for (name, url) in &to_add {
println!(" [+] ADD: {} (Source: {})", name, url);
}
for name in &to_delete {
if args.no_delete {
println!(" [~] SKIP DELETE: {} (--no-delete active)", name);
} else {
println!(" [-] DELETE: {}", name);
}
}
println!("----------------------");
if args.no_delete {
println!(
"Summary: {} to add, {} to delete (SKIPPED), {} unchanged.",
to_add.len(),
to_delete.len(),
to_keep.len()
);
} else {
println!(
"Summary: {} to add, {} to delete, {} unchanged.",
to_add.len(),
to_delete.len(),
to_keep.len()
);
}
// If nothing to add, and (deletes are empty OR we are skipping deletes), then done.
if to_add.is_empty() && (to_delete.is_empty() || args.no_delete) {
info!("Sync complete. No changes to apply.");
return Ok(());
}
// 6. Confirmation / Dry Run
if args.dry_run {
info!("Dry run enabled. Exiting without changes.");
return Ok(());
}
if !args.no_confirm {
print!("\nProceed with these changes? [y/N]: ");
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
if !input.trim().eq_ignore_ascii_case("y") {
info!("Aborted by user.");
return Ok(());
}
}
// 7. Execute
// Additions
for (name, url) in to_add {
info!("Migrating {}...", name);
let payload = MigrateRepoPayload {
clone_addr: &url,
repo_name: &name,
repo_owner: &owner_name,
mirror: true,
private: false,
description: "Mirrored via gitea-mirror",
};
match create_migration(&http_client, &config.gitea_url, &final_api_key, &payload).await {
Ok(_) => info!("Successfully migrated {}", name),
Err(e) => error!("Failed to migrate {}: {}", name, e),
}
}
// Deletions
if !args.no_delete {
for name in to_delete {
info!("Deleting {}...", name);
match delete_repo(
&http_client,
&config.gitea_url,
&final_api_key,
&owner_name,
&name,
)
.await
{
Ok(_) => info!("Successfully deleted {}", name),
Err(e) => error!("Failed to delete {}: {}", name, e),
}
}
} else if !to_delete.is_empty() {
info!("Skipping deletions due to --no-delete flag.");
}
info!("Process completed.");
Ok(())
}
// --- Helpers ---
#[instrument(skip(path))]
fn load_config(path: &Path) -> Result<Config, Box<dyn std::error::Error>> {
let content = fs::read_to_string(path)?;
let config: Config = toml::from_str(&content)?;
Ok(config)
}
fn extract_repo_name(url: &str) -> Option<&str> {
url.split('/').last().map(|s| s.trim_end_matches(".git"))
}
// --- API Calls ---
async fn get_authenticated_username(
client: &reqwest::Client,
base_url: &str,
api_key: &str,
) -> Result<String, reqwest::Error> {
let url = format!("{}/api/v1/user", base_url);
let user: GiteaUser = client
.get(&url)
.bearer_auth(api_key)
.send()
.await?
.error_for_status()?
.json::<GiteaUser>()
.await
.context("Failed to get Gitea user info. Check your API key and Gitea URL.")?;
info!("Authenticated as user '{}' (ID: {})", user.login, user.id);
.json()
.await?;
Ok(user.login)
}
// **MODIFIED**: We now build two sets: one for source URLs and one for existing repo names.
info!("🔍 Fetching all existing repositories to build a local cache...");
let mut existing_mirror_sources: HashSet<String> = HashSet::new();
let mut existing_repo_names: HashSet<String> = HashSet::new();
/// Fetches ALL repos for the target owner on the Gitea instance.
async fn fetch_all_target_repos(
client: &reqwest::Client,
gitea_url: &str,
api_key: &str,
owner: &str,
) -> Result<Vec<String>, Box<dyn std::error::Error>> {
let mut names = Vec::new();
let mut page = 1;
let base_url = format!("{}/api/v1/repos/search", gitea_url);
loop {
let repos_url = format!("{}/api/v1/user/repos", config.gitea_url);
let repos_on_page = client
.get(&repos_url)
.query(&[("limit", "50"), ("page", &page.to_string())])
let params = [
("owner", owner),
("limit", "50"),
("page", &page.to_string()),
];
let res = client
.get(&base_url)
.bearer_auth(api_key)
.query(&params)
.send()
.await?
.error_for_status()?
.json::<Vec<GiteaRepo>>()
.await
.context("Failed to fetch a page of existing repositories.")?;
.error_for_status()?;
if repos_on_page.is_empty() {
let json: serde_json::Value = res.json().await?;
let data = json
.get("data")
.and_then(|d| d.as_array())
.ok_or("Invalid API response")?;
if data.is_empty() {
break;
}
for repo in repos_on_page {
// Add the name of EVERY repo to prevent any name collisions.
existing_repo_names.insert(repo.name);
for repo in data {
if let Some(name) = repo.get("name").and_then(|n| n.as_str()) {
names.push(name.to_string());
}
}
page += 1;
}
Ok(names)
}
// If it's a mirror, store its ORIGINAL source URL for an exact match.
if repo.mirror {
if let Some(mirror_url) = repo.mirror_url {
existing_mirror_sources.insert(mirror_url);
}
/// Fetches clone URLs from external source (GitHub/Gitea).
async fn fetch_external_org_repos(
client: &reqwest::Client,
org_url: &str,
api_key: Option<&str>,
) -> Result<Vec<String>, Box<dyn std::error::Error>> {
let api_url = if org_url.contains("github.com") {
let parts: Vec<&str> = org_url.trim_end_matches('/').split('/').collect();
let user_or_org = parts.last().ok_or("Invalid GitHub URL")?;
format!("https://api.github.com/users/{}/repos", user_or_org)
} else {
// Assuming Gitea
let parts: Vec<&str> = org_url.trim_end_matches('/').split('/').collect();
let user_or_org = parts.last().ok_or("Invalid Gitea URL")?;
// Heuristic to find API endpoint from web URL
format!(
"{}s/{}/repos",
org_url.replace(user_or_org, &format!("api/v1/user")),
user_or_org
)
};
let mut repos = Vec::new();
let mut page = 1;
loop {
let mut req = client
.get(&api_url)
.query(&[("page", page.to_string())])
.header("User-Agent", "gitea-mirror-rust");
if let Some(key) = api_key {
req = req.bearer_auth(key);
}
let res = req.send().await?.error_for_status()?;
let json: Vec<serde_json::Value> = res.json().await?;
if json.is_empty() {
break;
}
for repo in json {
if let Some(url) = repo.get("clone_url").and_then(|u| u.as_str()) {
repos.push(url.to_string());
}
}
page += 1;
}
info!(
"Found {} existing repositories and {} configured mirrors.",
existing_repo_names.len(),
existing_mirror_sources.len()
);
Ok(repos)
}
// **MODIFIED**: The main checking logic is now much more robust.
for repo_config in &config.repos {
let url_to_mirror = &repo_config.url;
// CHECK 1: Has this exact source URL already been mirrored?
if existing_mirror_sources.contains(url_to_mirror) {
info!(
"Mirror for source URL '{}' already exists. Skipping.",
url_to_mirror
);
continue;
}
// Determine the target name for the new repository.
let target_repo_name = match &repo_config.rename {
Some(name) => name.clone(),
None => get_repo_name_from_url(url_to_mirror).with_context(|| {
format!("Could not parse repo name from URL: {}", url_to_mirror)
})?,
};
// CHECK 2: Will creating this mirror cause a name collision?
if existing_repo_names.contains(&target_repo_name) {
warn!(
"Cannot create mirror for '{}'. A repository named '{}' already exists. Skipping.",
url_to_mirror, target_repo_name
);
continue;
}
// If both checks pass, we are clear to create the migration.
info!(
"Mirror for '{}' not found and name '{}' is available. Needs creation.",
url_to_mirror, target_repo_name
);
if cli.dry_run {
warn!(
"--dry-run enabled, skipping migration for '{}'.",
url_to_mirror
);
continue;
}
let migration_payload = MigrationRequest {
clone_addr: url_to_mirror,
uid: user.id,
repo_name: &target_repo_name,
mirror: true,
private: false,
description: format!("Mirror of {}", url_to_mirror),
};
info!(
"🚀 Creating migration for '{}' as new repo '{}'...",
url_to_mirror, target_repo_name
);
let migrate_url = format!("{}/api/v1/repos/migrate", config.gitea_url);
let response = client
.post(&migrate_url)
.json(&migration_payload)
.send()
.await?;
if response.status().is_success() {
info!("Successfully initiated migration for '{}'.", url_to_mirror);
} else {
let status = response.status();
let error_body = response
.text()
.await
.unwrap_or_else(|_| "Could not read error body".to_string());
error!(
"Failed to create migration for '{}'. Status: {}. Body: {}",
url_to_mirror, status, error_body
);
}
}
info!("All tasks completed.");
async fn create_migration(
client: &reqwest::Client,
gitea_url: &str,
api_key: &str,
payload: &MigrateRepoPayload<'_>,
) -> Result<(), reqwest::Error> {
let url = format!("{}/api/v1/repos/migrate", gitea_url);
client
.post(&url)
.bearer_auth(api_key)
.json(payload)
.send()
.await?
.error_for_status()?;
Ok(())
}
fn get_repo_name_from_url(git_url: &str) -> Option<String> {
Url::parse(git_url)
.ok()
.and_then(|url| url.path_segments()?.last().map(|s| s.to_string()))
.map(|name| name.strip_suffix(".git").unwrap_or(&name).to_string())
async fn delete_repo(
client: &reqwest::Client,
gitea_url: &str,
api_key: &str,
owner: &str,
repo_name: &str,
) -> Result<(), reqwest::Error> {
let url = format!("{}/api/v1/repos/{}/{}", gitea_url, owner, repo_name);
client
.delete(&url)
.bearer_auth(api_key)
.send()
.await?
.error_for_status()?;
Ok(())
}