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?
```
This commit is contained in:
2025-11-18 18:16:12 -05:00
parent ae347d7506
commit 89d273c38e

View File

@@ -1,242 +1,313 @@
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. /// 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,
} }
// 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: String,
repos: Option<Vec<RepoConfig>>, repos: Option<Vec<RepoConfig>>,
organizations: Option<Vec<OrgConfig>>, organizations: Option<Vec<OrgConfig>>,
repo_owner: Option<String>, // Optional owner username/org for all migrated repos 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, // Username or organization name repo_owner: &'a str,
mirror: bool, mirror: bool,
private: bool, private: bool,
description: &'a str, description: &'a str,
} }
// Represents a user as returned by the Gitea API.
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
struct GiteaUser { struct GiteaUser {
login: String, 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();
// Determine the owner (either from repo_owner or authenticated user) // 1. Determine Target Owner
let owner_name = if let Some(owner) = &config.repo_owner { let owner_name = if let Some(owner) = &config.repo_owner {
info!("Using specified repo_owner: {}", owner);
owner.clone() owner.clone()
} else { } else {
info!("No repo_owner specified, fetching authenticated user");
get_authenticated_username(&http_client, &config.gitea_url, &config.api_key).await? get_authenticated_username(&http_client, &config.gitea_url, &config.api_key).await?
}; };
info!("Target Owner: {}", owner_name);
info!("Using owner '{}' for all migrated repositories", owner_name); // 2. Build 'Desired' State (Map<RepoName, CloneUrl>)
info!("Resolving desired state from configuration...");
let mut desired_repos: HashMap<String, String> = HashMap::new();
// Process repositories from the static list. // 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()
&owner_name, .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 {
match fetch_org_repos(&http_client, &org_config.url, org_config.api_key.as_deref()) if let Some(name) = extract_repo_name(&url) {
.await desired_repos.insert(name.to_string(), url);
{
Ok(repo_urls) => {
info!(
"Found {} repositories for {}",
repo_urls.len(),
org_config.url
);
for url in repo_urls {
process_repo(
&url,
None, // No rename support for orgs
&owner_name,
&http_client,
&config,
args.dry_run,
)
.await?;
}
} }
Err(e) => error!("Failed to fetch repos from {}: {}", org_config.url, e),
} }
} }
} }
info!("Gitea mirror process completed."); // 3. Build 'Current' State (Set<RepoName>)
info!("Fetching existing repositories from Gitea ({})", owner_name);
let existing_repos = fetch_all_target_repos(&http_client, &config, &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 {
println!(" [-] DELETE: {}", name);
}
println!("----------------------");
println!(
"Summary: {} to add, {} to delete, {} unchanged.",
to_add.len(),
to_delete.len(),
to_keep.len()
);
if to_add.is_empty() && to_delete.is_empty() {
info!("Sync complete. No changes detected.");
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, &payload).await {
Ok(_) => info!("Successfully migrated {}", name),
Err(e) => error!("Failed to migrate {}: {}", name, e),
}
}
// Deletions
for name in to_delete {
info!("Deleting {}...", name);
match delete_repo(&http_client, &config, &owner_name, &name).await {
Ok(_) => info!("Successfully deleted {}", name),
Err(e) => error!("Failed to delete {}: {}", name, e),
}
}
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 login name 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"))
}
// --- API Calls ---
async fn get_authenticated_username( async fn get_authenticated_username(
http_client: &reqwest::Client, client: &reqwest::Client,
gitea_url: &str, base_url: &str,
api_key: &str, api_key: &str,
) -> Result<String, 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?;
info!("Authenticated as user: {}", user.login);
Ok(user.login) 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))] /// Handles pagination to ensure we have the complete state for syncing.
async fn repo_exists( async fn fetch_all_target_repos(
http_client: &reqwest::Client, client: &reqwest::Client,
gitea_url: &str, config: &Config,
api_key: &str, owner: &str,
repo_name: &str, ) -> Result<Vec<String>, Box<dyn std::error::Error>> {
) -> Result<bool, reqwest::Error> { let mut names = Vec::new();
let url = format!("{}/api/v1/repos/search", gitea_url); let mut page = 1;
let response: serde_json::Value = http_client // Try organization endpoint first, fall back to user?
.get(&url) // Gitea distinguishes /orgs/{org}/repos and /users/{user}/repos.
.query(&[("q", repo_name), ("limit", "1")]) // To be safe, we search via search API restricted to owner, or try both.
.header("Authorization", format!("token {}", api_key)) // Simplest compliant way: /repos/search?uid={owner_id} or q=&owner={owner}
.send()
.await? // Let's use the specific search endpoint which is robust.
.error_for_status()? let base_url = format!("{}/api/v1/repos/search", config.gitea_url);
.json()
.await?; loop {
let params = [
("owner", owner),
("limit", "50"),
("page", &page.to_string()),
];
let res = client
.get(&base_url)
.bearer_auth(&config.api_key)
.query(&params)
.send()
.await?
.error_for_status()?;
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;
}
if let Some(data) = response.get("data").and_then(|d| d.as_array()) {
for repo in data { for repo in data {
if let Some(name) = repo.get("name").and_then(|n| n.as_str()) { if let Some(name) = repo.get("name").and_then(|n| n.as_str()) {
if name.eq_ignore_ascii_case(repo_name) { names.push(name.to_string());
return Ok(true);
}
} }
} }
page += 1;
} }
Ok(names)
Ok(false)
} }
/// Creates a mirror migration in Gitea. /// Fetches clone URLs from external source (GitHub/Gitea).
#[instrument(skip(http_client, config, payload))] async fn fetch_external_org_repos(
async fn create_migration( client: &reqwest::Client,
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()
.await?
.error_for_status()?;
Ok(())
}
/// Fetches all repository clone URLs from a given Gitea/GitHub organization/user page.
#[instrument(skip(http_client, api_key))]
async fn fetch_org_repos(
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")),
@@ -244,35 +315,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;
@@ -281,53 +346,34 @@ async fn fetch_org_repos(
Ok(repos) Ok(repos)
} }
/// Core logic to process a single repository. async fn create_migration(
#[instrument(skip(owner_name, http_client, config, dry_run))] client: &reqwest::Client,
async fn process_repo(
repo_url: &str,
rename: Option<&str>,
owner_name: &str,
http_client: &reqwest::Client,
config: &Config, config: &Config,
dry_run: bool, payload: &MigrateRepoPayload<'_>,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), reqwest::Error> {
let repo_name = match rename { let url = format!("{}/api/v1/repos/migrate", config.gitea_url);
Some(name) => name, client
None => extract_repo_name(repo_url).ok_or("Could not extract repo name from URL")?, .post(&url)
}; .bearer_auth(&config.api_key)
.json(payload)
info!("Processing repo '{}' -> '{}'", repo_url, repo_name); .send()
.await?
if repo_exists(http_client, &config.gitea_url, &config.api_key, repo_name).await? { .error_for_status()?;
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,
repo_owner: owner_name,
mirror: true,
private: false, // Defaulting to public, change if needed
description: "",
};
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")) config: &Config,
owner: &str,
repo_name: &str,
) -> Result<(), reqwest::Error> {
let url = format!("{}/api/v1/repos/{}/{}", config.gitea_url, owner, repo_name);
client
.delete(&url)
.bearer_auth(&config.api_key)
.send()
.await?
.error_for_status()?;
Ok(())
} }