Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
d8fd1ac57d
|
|||
|
eeeb42b48b
|
|||
|
5fc739be23
|
|||
|
8f142e07ba
|
|||
|
89d273c38e
|
|||
|
ae347d7506
|
1189
Cargo.lock
generated
1189
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -4,9 +4,9 @@ version = "0.1.0"
|
|||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
clap = { version = "4.0", features = ["derive", "env"] }
|
clap = { version = "4.5", features = ["derive", "env"] }
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
reqwest = { version = "0.12", features = ["json"] }
|
reqwest = { version = "0.13", features = ["json", "query"] }
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
toml = "0.9"
|
toml = "0.9"
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
479
src/main.rs
479
src/main.rs
@@ -1,272 +1,392 @@
|
|||||||
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;
|
|
||||||
|
|
||||||
// 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('/').next_back().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 org_url = format!("{}/api/v1/orgs/{}/repos", gitea_url, owner);
|
||||||
let response: serde_json::Value = http_client
|
match fetch_repos_from_endpoint(client, &org_url, api_key).await {
|
||||||
.get(&url)
|
Ok(repos) => Ok(repos),
|
||||||
.query(&[("q", repo_name), ("limit", "1")])
|
Err(e) => {
|
||||||
.header("Authorization", format!("token {}", api_key))
|
if e.downcast_ref::<reqwest::Error>()
|
||||||
.send()
|
.is_some_and(|r| r.status() == Some(reqwest::StatusCode::NOT_FOUND))
|
||||||
.await?
|
{
|
||||||
.error_for_status()?
|
info!("Owner '{}' not found as org, trying as user...", owner);
|
||||||
.json()
|
let user_url = format!("{}/api/v1/users/{}/repos", gitea_url, owner);
|
||||||
.await?;
|
return fetch_repos_from_endpoint(client, &user_url, api_key).await;
|
||||||
|
|
||||||
if let Some(data) = response.get("data").and_then(|d| d.as_array()) {
|
|
||||||
for repo in data {
|
|
||||||
if let Some(name) = repo.get("name").and_then(|n| n.as_str()) {
|
|
||||||
if name.eq_ignore_ascii_case(repo_name) {
|
|
||||||
return Ok(true);
|
|
||||||
}
|
}
|
||||||
|
Err(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(false)
|
async fn fetch_repos_from_endpoint(
|
||||||
}
|
client: &reqwest::Client,
|
||||||
|
url: &str,
|
||||||
|
api_key: &str,
|
||||||
|
) -> Result<Vec<String>, Box<dyn std::error::Error>> {
|
||||||
|
let mut names = Vec::new();
|
||||||
|
let mut page = 1;
|
||||||
|
|
||||||
/// Creates a mirror migration in Gitea.
|
loop {
|
||||||
#[instrument(skip(http_client, config, payload))]
|
let params = [("limit", "50"), ("page", &page.to_string())];
|
||||||
async fn create_migration(
|
|
||||||
http_client: &reqwest::Client,
|
let res = client
|
||||||
config: &Config,
|
.get(url)
|
||||||
payload: &MigrateRepoPayload<'_>,
|
.bearer_auth(api_key)
|
||||||
) -> Result<(), reqwest::Error> {
|
.query(¶ms)
|
||||||
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.as_array().ok_or("Invalid API response")?;
|
||||||
|
|
||||||
|
if data.is_empty() {
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetches all repository clone URLs from a given Gitea/GitHub organization/user page.
|
for repo in data {
|
||||||
#[instrument(skip(http_client, api_key))]
|
if let Some(name) = repo.get("name").and_then(|n| n.as_str()) {
|
||||||
async fn fetch_org_repos(
|
names.push(name.to_string());
|
||||||
http_client: &reqwest::Client,
|
}
|
||||||
|
}
|
||||||
|
page += 1;
|
||||||
|
}
|
||||||
|
Ok(names)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches clone URLs from external source (GitHub/Gitea).
|
||||||
|
async fn fetch_external_org_repos(
|
||||||
|
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, "api/v1/user"),
|
||||||
user_or_org
|
user_or_org
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
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 +395,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(())
|
||||||
}
|
}
|
||||||
|
|||||||
22
vibe_coding_log/README.md
Normal file
22
vibe_coding_log/README.md
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Vibe Coding Log
|
||||||
|
|
||||||
|
This directory contains logs of coding sessions with LLM assistants. The purpose is to track the evolution of LLM coding capabilities over time and provide an audit trail of changes made to the codebase.
|
||||||
|
|
||||||
|
## Log Structure
|
||||||
|
|
||||||
|
Each log entry is saved as a Markdown file named with the pattern:
|
||||||
|
`session_<YYYY_MM_DD>_<topic>.md`
|
||||||
|
|
||||||
|
## Instructions for Creating New Entries
|
||||||
|
|
||||||
|
1. **Create a new file**: Use the naming convention above.
|
||||||
|
2. **Record the Session**: Copy the conversation transcript or write a detailed summary of the interaction.
|
||||||
|
3. **Metadata**: Include the following at the top of the file:
|
||||||
|
* **Date**: The date of the session.
|
||||||
|
* **Model**: The name/version of the LLM used (e.g., Gemini 2.5 Flash, GPT-4, etc.).
|
||||||
|
* **Goal**: The primary objective of the session.
|
||||||
|
* **Outcome**: A brief summary of the result.
|
||||||
|
|
||||||
|
## Current Assistant
|
||||||
|
|
||||||
|
The initial logs in this folder were generated by **Gemini** (specifically acting as the Gemini CLI agent).
|
||||||
69
vibe_coding_log/session_2026_01_07_gitea_fix.md
Normal file
69
vibe_coding_log/session_2026_01_07_gitea_fix.md
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
# Session Log: Gitea Mirror Fixes
|
||||||
|
|
||||||
|
**Date**: Wednesday, January 7, 2026
|
||||||
|
**Model**: Gemini (CLI Agent)
|
||||||
|
**Goal**: Fix compiler errors after dependency bump and correct Gitea API usage.
|
||||||
|
**Outcome**: Successfully resolved `reqwest` 0.13 breaking changes and fixed logic for fetching repositories.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Transcript Summary
|
||||||
|
|
||||||
|
### 1. Dependency Update Fix
|
||||||
|
|
||||||
|
**User**: Reported a compiler error after bumping dependencies.
|
||||||
|
**Context**: The project uses `reqwest`. A recent update to `reqwest` 0.13 made the `.query()` method feature-gated.
|
||||||
|
**Error**:
|
||||||
|
```
|
||||||
|
error[E0599]: no method named `query` found for struct `RequestBuilder` in the current scope
|
||||||
|
```
|
||||||
|
|
||||||
|
**Agent Action**:
|
||||||
|
1. Analyzed `Cargo.toml` and build output.
|
||||||
|
2. Identified that `reqwest` 0.13 requires the `query` feature to use the query string builder.
|
||||||
|
3. Updated `Cargo.toml`:
|
||||||
|
```toml
|
||||||
|
reqwest = { version = "0.13", features = ["json", "query"] }
|
||||||
|
```
|
||||||
|
4. Verified with `cargo build`.
|
||||||
|
5. Committed changes: "Fix compilation error by enabling reqwest/query feature".
|
||||||
|
|
||||||
|
### 2. Logic Fix and Refactoring
|
||||||
|
|
||||||
|
**User**: Provided a `git diff` from another machine fixing an issue where Gitea's search API ignored the "owner" field, causing incorrect repo mirroring. Also requested incorporation of the fix.
|
||||||
|
|
||||||
|
**Agent Action**:
|
||||||
|
1. Applied the logic change to `src/main.rs`:
|
||||||
|
* Switched from `/api/v1/repos/search` to `/api/v1/orgs/{owner}/repos`.
|
||||||
|
* Updated response parsing to handle the JSON array directly.
|
||||||
|
2. Addressed `cargo clippy` warnings identified during the process:
|
||||||
|
* Removed unused `tracing_subscriber` import.
|
||||||
|
* Optimized iterator usage: `url.split('/').last()` -> `url.split('/').next_back()`.
|
||||||
|
* Removed redundant `format!` usage.
|
||||||
|
3. Verified with `cargo build` and `cargo clippy`.
|
||||||
|
4. Committed changes: "Fix target repo fetching by using orgs endpoint and address clippy warnings".
|
||||||
|
|
||||||
|
### 3. Verification and Robustness
|
||||||
|
|
||||||
|
**User**: Asked to verify if the logic fix (using `/orgs/{owner}/repos`) was correct via rigorous checking.
|
||||||
|
|
||||||
|
**Analysis**:
|
||||||
|
* Research confirmed that `/repos/search` does not support filtering by `owner` name (only `uid`), validating the need for a change.
|
||||||
|
* However, `/orgs/{owner}/repos` **only** works if the target is an organization. If the target is a User, it would fail.
|
||||||
|
* The correct endpoint for users is `/users/{owner}/repos`.
|
||||||
|
|
||||||
|
**Agent Action**:
|
||||||
|
1. Refactored `fetch_all_target_repos` to use a fallback strategy:
|
||||||
|
* First, try fetching from `/api/v1/orgs/{owner}/repos`.
|
||||||
|
* If the API returns `404 Not Found`, automatically retry with `/api/v1/users/{owner}/repos`.
|
||||||
|
2. Extracted the fetching logic into a helper function `fetch_repos_from_endpoint` to avoid duplication.
|
||||||
|
3. Addressed new `clippy` suggestions regarding `map_or` and `is_some_and`.
|
||||||
|
4. Committed changes: "Implement fallback to user repos endpoint if org not found".
|
||||||
|
|
||||||
|
## Final State
|
||||||
|
|
||||||
|
The codebase now:
|
||||||
|
1. Compiles with the latest dependencies (`reqwest` 0.13).
|
||||||
|
2. Correctly filters repositories by the target owner.
|
||||||
|
3. Robustly handles both Organization and User targets by attempting both endpoints.
|
||||||
|
4. Is free of linting warnings.
|
||||||
Reference in New Issue
Block a user