Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
5fc739be23
|
|||
|
8f142e07ba
|
|||
|
89d273c38e
|
|||
|
ae347d7506
|
@@ -4,6 +4,10 @@ gitea_url = "https://gitmirror.hak8or.com"
|
|||||||
# Your Gitea API key (generate one from User Settings -> Applications)
|
# Your Gitea API key (generate one from User Settings -> Applications)
|
||||||
api_key = "API_KEY_GOES_HERE"
|
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.
|
# A list of remote git repositories to mirror.
|
||||||
repos = [
|
repos = [
|
||||||
{ url = "https://gitea.hak8or.com/hak8or/gitea_mirror.git" },
|
{ url = "https://gitea.hak8or.com/hak8or/gitea_mirror.git" },
|
||||||
|
|||||||
471
src/main.rs
471
src/main.rs
@@ -1,236 +1,350 @@
|
|||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
use std::io::{self, Write};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use tracing::{Level, error, info, instrument, warn};
|
use tracing::{Level, error, info, instrument, warn};
|
||||||
use tracing_subscriber;
|
use tracing_subscriber;
|
||||||
|
|
||||||
// Represents the command-line arguments.
|
|
||||||
#[derive(Parser, Debug)]
|
#[derive(Parser, Debug)]
|
||||||
#[command(name = "gitea-mirror")]
|
#[command(name = "gitea-mirror")]
|
||||||
#[command(
|
#[command(about = "Syncs Git repositories to Gitea based on a TOML config.")]
|
||||||
about = "Ensures Git repositories are mirrored to Gitea, generated with Gemini 2.5 Web Canvas"
|
|
||||||
)]
|
|
||||||
#[clap(author, version, about, long_about = None)]
|
|
||||||
struct Args {
|
struct Args {
|
||||||
/// Path to the TOML configuration file.
|
/// Path to the TOML configuration file.
|
||||||
#[clap(short, long, value_parser, env = "GITEA_MIRROR_CONFIG_FILEPATH")]
|
#[clap(short, long, value_parser, env = "GITEA_MIRROR_CONFIG_FILEPATH")]
|
||||||
config: PathBuf,
|
config: PathBuf,
|
||||||
|
|
||||||
/// Perform a dry run without creating any migrations.
|
/// 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)]
|
#[clap(short, long, default_value_t = false)]
|
||||||
dry_run: bool,
|
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,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Represents a single repository entry in the config file.
|
|
||||||
#[derive(Deserialize, Debug, Clone)]
|
#[derive(Deserialize, Debug, Clone)]
|
||||||
struct RepoConfig {
|
struct RepoConfig {
|
||||||
url: String,
|
url: String,
|
||||||
rename: Option<String>,
|
rename: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Represents a single organization entry in the config file.
|
|
||||||
#[derive(Deserialize, Debug, Clone)]
|
#[derive(Deserialize, Debug, Clone)]
|
||||||
struct OrgConfig {
|
struct OrgConfig {
|
||||||
url: String,
|
url: String,
|
||||||
api_key: Option<String>,
|
api_key: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Represents the main structure of the TOML configuration file.
|
|
||||||
#[derive(Deserialize, Debug)]
|
#[derive(Deserialize, Debug)]
|
||||||
struct Config {
|
struct Config {
|
||||||
gitea_url: String,
|
gitea_url: String,
|
||||||
api_key: String,
|
api_key: Option<String>,
|
||||||
repos: Option<Vec<RepoConfig>>,
|
repos: Option<Vec<RepoConfig>>,
|
||||||
organizations: Option<Vec<OrgConfig>>,
|
organizations: Option<Vec<OrgConfig>>,
|
||||||
|
repo_owner: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Represents the payload for creating a migration in Gitea.
|
|
||||||
#[derive(serde::Serialize, Debug)]
|
#[derive(serde::Serialize, Debug)]
|
||||||
struct MigrateRepoPayload<'a> {
|
struct MigrateRepoPayload<'a> {
|
||||||
clone_addr: &'a str,
|
clone_addr: &'a str,
|
||||||
repo_name: &'a str,
|
repo_name: &'a str,
|
||||||
|
repo_owner: &'a str,
|
||||||
mirror: bool,
|
mirror: bool,
|
||||||
private: bool,
|
private: bool,
|
||||||
description: &'a str,
|
description: &'a str,
|
||||||
uid: i64, // The user ID of the owner. We'll fetch this.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Represents a user as returned by the Gitea API.
|
|
||||||
#[derive(Deserialize, Debug)]
|
#[derive(Deserialize, Debug)]
|
||||||
struct GiteaUser {
|
struct GiteaUser {
|
||||||
id: i64,
|
login: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Entry point of the application.
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
// Initialize the tracing subscriber for logging.
|
|
||||||
tracing_subscriber::fmt().with_max_level(Level::INFO).init();
|
tracing_subscriber::fmt().with_max_level(Level::INFO).init();
|
||||||
|
|
||||||
// Parse command-line arguments or get config path from environment variable.
|
|
||||||
let args = Args::parse();
|
let args = Args::parse();
|
||||||
|
|
||||||
info!("Starting Gitea mirror process. Dry run: {}", args.dry_run);
|
|
||||||
|
|
||||||
// Read and parse the configuration file.
|
|
||||||
let config = load_config(&args.config)?;
|
let config = load_config(&args.config)?;
|
||||||
let http_client = reqwest::Client::new();
|
let http_client = reqwest::Client::new();
|
||||||
|
|
||||||
// Fetch the Gitea user ID for the authenticated user.
|
// Resolve API Key: CLI/Env > Config File
|
||||||
let user_id = get_gitea_user_id(&http_client, &config.gitea_url, &config.api_key).await?;
|
let final_api_key = args
|
||||||
info!(
|
.api_key
|
||||||
"Successfully authenticated and retrieved user ID: {}",
|
.or(config.api_key.clone())
|
||||||
user_id
|
.ok_or("API Key must be provided via --api-key, GITEA_MIRROR_API_KEY, or config file.")?;
|
||||||
);
|
|
||||||
|
|
||||||
// Process repositories from the static list.
|
// 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 {
|
if let Some(repos) = &config.repos {
|
||||||
for repo_config in repos {
|
for r in repos {
|
||||||
process_repo(
|
let name = r
|
||||||
&repo_config.url,
|
.rename
|
||||||
repo_config.rename.as_deref(),
|
.as_deref()
|
||||||
user_id,
|
.or_else(|| extract_repo_name(&r.url))
|
||||||
&http_client,
|
.ok_or_else(|| format!("Invalid URL: {}", r.url))?;
|
||||||
&config,
|
desired_repos.insert(name.to_string(), r.url.clone());
|
||||||
args.dry_run,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process repositories from the organizations/users list.
|
// 2b. Organization Repos
|
||||||
if let Some(org_configs) = &config.organizations {
|
if let Some(orgs) = &config.organizations {
|
||||||
for org_config in org_configs {
|
for org in orgs {
|
||||||
info!(
|
info!("Fetching repos from source: {}", org.url);
|
||||||
"Fetching repositories from organization: {}",
|
let urls =
|
||||||
org_config.url
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
|
||||||
|
// 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()
|
||||||
);
|
);
|
||||||
match fetch_org_repos(&http_client, &org_config.url, org_config.api_key.as_deref())
|
} 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
|
.await
|
||||||
{
|
{
|
||||||
Ok(repo_urls) => {
|
Ok(_) => info!("Successfully deleted {}", name),
|
||||||
info!(
|
Err(e) => error!("Failed to delete {}: {}", name, e),
|
||||||
"Found {} repositories for {}",
|
|
||||||
repo_urls.len(),
|
|
||||||
org_config.url
|
|
||||||
);
|
|
||||||
for url in repo_urls {
|
|
||||||
process_repo(
|
|
||||||
&url,
|
|
||||||
None, // No rename support for orgs
|
|
||||||
user_id,
|
|
||||||
&http_client,
|
|
||||||
&config,
|
|
||||||
args.dry_run,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => error!("Failed to fetch repos from {}: {}", org_config.url, e),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if !to_delete.is_empty() {
|
||||||
|
info!("Skipping deletions due to --no-delete flag.");
|
||||||
}
|
}
|
||||||
|
|
||||||
info!("Gitea mirror process completed.");
|
info!("Process completed.");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Loads and parses the TOML configuration file.
|
// --- Helpers ---
|
||||||
|
|
||||||
#[instrument(skip(path))]
|
#[instrument(skip(path))]
|
||||||
fn load_config(path: &Path) -> Result<Config, Box<dyn std::error::Error>> {
|
fn load_config(path: &Path) -> Result<Config, Box<dyn std::error::Error>> {
|
||||||
info!("Loading configuration from: {:?}", path);
|
|
||||||
let content = fs::read_to_string(path)?;
|
let content = fs::read_to_string(path)?;
|
||||||
let config: Config = toml::from_str(&content)?;
|
let config: Config = toml::from_str(&content)?;
|
||||||
Ok(config)
|
Ok(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetches the authenticated user's ID from Gitea.
|
fn extract_repo_name(url: &str) -> Option<&str> {
|
||||||
#[instrument(skip(http_client, gitea_url, api_key))]
|
url.split('/').last().map(|s| s.trim_end_matches(".git"))
|
||||||
async fn get_gitea_user_id(
|
}
|
||||||
http_client: &reqwest::Client,
|
|
||||||
gitea_url: &str,
|
// --- API Calls ---
|
||||||
|
|
||||||
|
async fn get_authenticated_username(
|
||||||
|
client: &reqwest::Client,
|
||||||
|
base_url: &str,
|
||||||
api_key: &str,
|
api_key: &str,
|
||||||
) -> Result<i64, reqwest::Error> {
|
) -> Result<String, reqwest::Error> {
|
||||||
let url = format!("{}/api/v1/user", gitea_url);
|
let url = format!("{}/api/v1/user", base_url);
|
||||||
let user: GiteaUser = http_client
|
let user: GiteaUser = client
|
||||||
.get(&url)
|
.get(&url)
|
||||||
.header("Authorization", format!("token {}", api_key))
|
.bearer_auth(api_key)
|
||||||
.send()
|
.send()
|
||||||
.await?
|
.await?
|
||||||
.error_for_status()?
|
.error_for_status()?
|
||||||
.json()
|
.json()
|
||||||
.await?;
|
.await?;
|
||||||
Ok(user.id)
|
Ok(user.login)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Checks if a repository already exists in Gitea for the user.
|
/// Fetches ALL repos for the target owner on the Gitea instance.
|
||||||
#[instrument(skip(http_client, gitea_url, api_key))]
|
async fn fetch_all_target_repos(
|
||||||
async fn repo_exists(
|
client: &reqwest::Client,
|
||||||
http_client: &reqwest::Client,
|
|
||||||
gitea_url: &str,
|
gitea_url: &str,
|
||||||
api_key: &str,
|
api_key: &str,
|
||||||
repo_name: &str,
|
owner: &str,
|
||||||
) -> Result<bool, reqwest::Error> {
|
) -> Result<Vec<String>, Box<dyn std::error::Error>> {
|
||||||
let url = format!("{}/api/v1/repos/search", gitea_url);
|
let mut names = Vec::new();
|
||||||
let response: serde_json::Value = http_client
|
let mut page = 1;
|
||||||
.get(&url)
|
let base_url = format!("{}/api/v1/repos/search", gitea_url);
|
||||||
.query(&[("q", repo_name), ("limit", "1")])
|
|
||||||
.header("Authorization", format!("token {}", api_key))
|
|
||||||
.send()
|
|
||||||
.await?
|
|
||||||
.error_for_status()?
|
|
||||||
.json()
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if let Some(data) = response.get("data").and_then(|d| d.as_array()) {
|
loop {
|
||||||
for repo in data {
|
let params = [
|
||||||
if let Some(name) = repo.get("name").and_then(|n| n.as_str()) {
|
("owner", owner),
|
||||||
if name.eq_ignore_ascii_case(repo_name) {
|
("limit", "50"),
|
||||||
return Ok(true);
|
("page", &page.to_string()),
|
||||||
}
|
];
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(false)
|
let res = client
|
||||||
}
|
.get(&base_url)
|
||||||
|
.bearer_auth(api_key)
|
||||||
/// Creates a mirror migration in Gitea.
|
.query(¶ms)
|
||||||
#[instrument(skip(http_client, config, payload))]
|
|
||||||
async fn create_migration(
|
|
||||||
http_client: &reqwest::Client,
|
|
||||||
config: &Config,
|
|
||||||
payload: &MigrateRepoPayload<'_>,
|
|
||||||
) -> Result<(), reqwest::Error> {
|
|
||||||
let url = format!("{}/api/v1/repos/migrate", config.gitea_url);
|
|
||||||
http_client
|
|
||||||
.post(&url)
|
|
||||||
.header("Authorization", format!("token {}", config.api_key))
|
|
||||||
.json(payload)
|
|
||||||
.send()
|
.send()
|
||||||
.await?
|
.await?
|
||||||
.error_for_status()?;
|
.error_for_status()?;
|
||||||
Ok(())
|
|
||||||
|
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 data {
|
||||||
|
if let Some(name) = repo.get("name").and_then(|n| n.as_str()) {
|
||||||
|
names.push(name.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
page += 1;
|
||||||
|
}
|
||||||
|
Ok(names)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetches all repository clone URLs from a given Gitea/GitHub organization/user page.
|
/// Fetches clone URLs from external source (GitHub/Gitea).
|
||||||
#[instrument(skip(http_client, api_key))]
|
async fn fetch_external_org_repos(
|
||||||
async fn fetch_org_repos(
|
client: &reqwest::Client,
|
||||||
http_client: &reqwest::Client,
|
|
||||||
org_url: &str,
|
org_url: &str,
|
||||||
api_key: Option<&str>,
|
api_key: Option<&str>,
|
||||||
) -> Result<Vec<String>, Box<dyn std::error::Error>> {
|
) -> Result<Vec<String>, Box<dyn std::error::Error>> {
|
||||||
// This is a simplified fetcher. It assumes Gitea API compatibility.
|
|
||||||
// For GitHub, you might need a different base URL and auth method.
|
|
||||||
let api_url = if org_url.contains("github.com") {
|
let api_url = if org_url.contains("github.com") {
|
||||||
let parts: Vec<&str> = org_url.trim_end_matches('/').split('/').collect();
|
let parts: Vec<&str> = org_url.trim_end_matches('/').split('/').collect();
|
||||||
let user_or_org = parts.last().ok_or("Invalid GitHub URL")?;
|
let user_or_org = parts.last().ok_or("Invalid GitHub URL")?;
|
||||||
format!("https://api.github.com/users/{}/repos", user_or_org)
|
format!("https://api.github.com/users/{}/repos", user_or_org)
|
||||||
} else {
|
} else {
|
||||||
// Assuming Gitea-like URL structure
|
// Assuming Gitea
|
||||||
let parts: Vec<&str> = org_url.trim_end_matches('/').split('/').collect();
|
let parts: Vec<&str> = org_url.trim_end_matches('/').split('/').collect();
|
||||||
let user_or_org = parts.last().ok_or("Invalid Gitea URL")?;
|
let user_or_org = parts.last().ok_or("Invalid Gitea URL")?;
|
||||||
|
// Heuristic to find API endpoint from web URL
|
||||||
format!(
|
format!(
|
||||||
"{}s/{}/repos",
|
"{}s/{}/repos",
|
||||||
org_url.replace(user_or_org, &format!("api/v1/user")),
|
org_url.replace(user_or_org, &format!("api/v1/user")),
|
||||||
@@ -238,35 +352,29 @@ async fn fetch_org_repos(
|
|||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
info!("Querying API endpoint: {}", api_url);
|
let mut repos = Vec::new();
|
||||||
|
|
||||||
let mut repos: Vec<String> = Vec::new();
|
|
||||||
let mut page = 1;
|
let mut page = 1;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let mut request_builder = http_client
|
let mut req = client
|
||||||
.get(&api_url)
|
.get(&api_url)
|
||||||
.query(&[("page", page.to_string())])
|
.query(&[("page", page.to_string())])
|
||||||
// For GitHub, a User-Agent is required.
|
.header("User-Agent", "gitea-mirror-rust");
|
||||||
.header("User-Agent", "gitea-mirror-rust-client");
|
|
||||||
|
|
||||||
if let Some(key) = api_key {
|
if let Some(key) = api_key {
|
||||||
request_builder = request_builder.header("Authorization", format!("token {}", key));
|
req = req.bearer_auth(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
let response: Vec<serde_json::Value> = request_builder
|
let res = req.send().await?.error_for_status()?;
|
||||||
.send()
|
let json: Vec<serde_json::Value> = res.json().await?;
|
||||||
.await?
|
|
||||||
.error_for_status()?
|
|
||||||
.json()
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if response.is_empty() {
|
if json.is_empty() {
|
||||||
break; // No more pages
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
for repo in response {
|
for repo in json {
|
||||||
if let Some(clone_url) = repo.get("clone_url").and_then(|u| u.as_str()) {
|
if let Some(url) = repo.get("clone_url").and_then(|u| u.as_str()) {
|
||||||
repos.push(clone_url.to_string());
|
repos.push(url.to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
page += 1;
|
page += 1;
|
||||||
@@ -275,53 +383,36 @@ async fn fetch_org_repos(
|
|||||||
Ok(repos)
|
Ok(repos)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Core logic to process a single repository.
|
async fn create_migration(
|
||||||
#[instrument(skip(user_id, http_client, config, dry_run))]
|
client: &reqwest::Client,
|
||||||
async fn process_repo(
|
gitea_url: &str,
|
||||||
repo_url: &str,
|
api_key: &str,
|
||||||
rename: Option<&str>,
|
payload: &MigrateRepoPayload<'_>,
|
||||||
user_id: i64,
|
) -> Result<(), reqwest::Error> {
|
||||||
http_client: &reqwest::Client,
|
let url = format!("{}/api/v1/repos/migrate", gitea_url);
|
||||||
config: &Config,
|
client
|
||||||
dry_run: bool,
|
.post(&url)
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
.bearer_auth(api_key)
|
||||||
let repo_name = match rename {
|
.json(payload)
|
||||||
Some(name) => name,
|
.send()
|
||||||
None => extract_repo_name(repo_url).ok_or("Could not extract repo name from URL")?,
|
.await?
|
||||||
};
|
.error_for_status()?;
|
||||||
|
|
||||||
info!("Processing repo '{}' -> '{}'", repo_url, repo_name);
|
|
||||||
|
|
||||||
if repo_exists(http_client, &config.gitea_url, &config.api_key, repo_name).await? {
|
|
||||||
info!("Repo '{}' already exists. Skipping.", repo_name);
|
|
||||||
} else {
|
|
||||||
warn!("Repo '{}' does not exist. Migration needed.", repo_name);
|
|
||||||
if !dry_run {
|
|
||||||
info!("Initiating migration for '{}'...", repo_name);
|
|
||||||
let payload = MigrateRepoPayload {
|
|
||||||
clone_addr: repo_url,
|
|
||||||
repo_name,
|
|
||||||
mirror: true,
|
|
||||||
private: false, // Defaulting to public, change if needed
|
|
||||||
description: "",
|
|
||||||
uid: user_id,
|
|
||||||
};
|
|
||||||
if let Err(e) = create_migration(http_client, config, &payload).await {
|
|
||||||
error!("Failed to create migration for '{}': {}", repo_name, e);
|
|
||||||
} else {
|
|
||||||
info!("Successfully started migration for '{}'.", repo_name);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
info!(
|
|
||||||
"Dry run enabled. Skipping actual migration for '{}'.",
|
|
||||||
repo_name
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extracts a repository name from a git URL (e.g., "https://.../repo.git" -> "repo").
|
async fn delete_repo(
|
||||||
fn extract_repo_name(url: &str) -> Option<&str> {
|
client: &reqwest::Client,
|
||||||
url.split('/').last().map(|s| s.trim_end_matches(".git"))
|
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(())
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user