Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
d8fd1ac57d
|
|||
|
eeeb42b48b
|
|||
|
5fc739be23
|
|||
|
8f142e07ba
|
|||
|
89d273c38e
|
|||
|
ae347d7506
|
|||
|
fdb7cf7a4a
|
|||
|
3497cbaa6e
|
|||
|
129d67bc8b
|
|||
|
f732535db2
|
1197
Cargo.lock
generated
1197
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -4,13 +4,11 @@ version = "0.1.0"
|
|||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = "1.0.100"
|
|
||||||
clap = { version = "4.5", features = ["derive", "env"] }
|
clap = { version = "4.5", features = ["derive", "env"] }
|
||||||
reqwest = { version = "0.12.23", features = ["json"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
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"
|
||||||
tokio = { version = "1.35", features = ["full"] }
|
toml = "0.9"
|
||||||
toml = "0.9.7"
|
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = "0.3"
|
tracing-subscriber = "0.3"
|
||||||
url = "2.5.7"
|
|
||||||
|
|||||||
21
example.toml
Normal file
21
example.toml
Normal 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" },
|
||||||
|
]
|
||||||
555
src/main.rs
555
src/main.rs
@@ -1,229 +1,430 @@
|
|||||||
use anyhow::{Context, Result};
|
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use reqwest::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE, USER_AGENT};
|
use serde::Deserialize;
|
||||||
use serde::{Deserialize, Serialize};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::collections::HashSet;
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::io::{self, Write};
|
||||||
use tracing::{error, info, warn};
|
use std::path::{Path, PathBuf};
|
||||||
use url::Url;
|
use tracing::{Level, error, info, instrument, warn};
|
||||||
|
|
||||||
// --- Structs (Unchanged) ---
|
|
||||||
#[derive(Parser, Debug)]
|
#[derive(Parser, Debug)]
|
||||||
#[command(
|
#[command(name = "gitea-mirror")]
|
||||||
author,
|
#[command(about = "Syncs Git repositories to Gitea based on a TOML config.")]
|
||||||
version,
|
struct Args {
|
||||||
about = "A simple tool to ensure git repositories are mirrored to Gitea."
|
/// Path to the TOML configuration file.
|
||||||
)]
|
#[clap(short, long, value_parser, env = "GITEA_MIRROR_CONFIG_FILEPATH")]
|
||||||
struct Cli {
|
|
||||||
#[arg(short, long, env = "GITEA_MIRROR_CONFIG")]
|
|
||||||
config: PathBuf,
|
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,
|
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)]
|
#[derive(Deserialize, Debug, Clone)]
|
||||||
struct RepoToMirror {
|
struct RepoConfig {
|
||||||
url: String,
|
url: String,
|
||||||
rename: Option<String>,
|
rename: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug, Clone)]
|
||||||
|
struct OrgConfig {
|
||||||
|
url: String,
|
||||||
|
api_key: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Debug)]
|
#[derive(Deserialize, Debug)]
|
||||||
struct Config {
|
struct Config {
|
||||||
gitea_url: String,
|
gitea_url: String,
|
||||||
api_key: String,
|
api_key: Option<String>,
|
||||||
repos: Vec<RepoToMirror>,
|
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)]
|
#[derive(Deserialize, Debug)]
|
||||||
struct GiteaUser {
|
struct GiteaUser {
|
||||||
id: i64,
|
|
||||||
login: String,
|
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]
|
#[tokio::main]
|
||||||
async fn main() -> Result<()> {
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
tracing_subscriber::fmt::init();
|
tracing_subscriber::fmt().with_max_level(Level::INFO).init();
|
||||||
let cli = Cli::parse();
|
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)
|
// Resolve API Key: CLI/Env > Config File
|
||||||
.with_context(|| format!("Failed to read config file at {:?}", cli.config))?;
|
let final_api_key = args
|
||||||
let config: Config =
|
.api_key
|
||||||
toml::from_str(&config_content).context("Failed to parse TOML configuration")?;
|
.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 {
|
// 1. Determine Target Owner
|
||||||
info!("Performing a dry run. No migrations will be created.");
|
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();
|
// 2b. Organization Repos
|
||||||
headers.insert(ACCEPT, "application/json".parse()?);
|
if let Some(orgs) = &config.organizations {
|
||||||
headers.insert(CONTENT_TYPE, "application/json".parse()?);
|
for org in orgs {
|
||||||
headers.insert(USER_AGENT, "gitea-mirror-tool/0.1.0".parse()?);
|
info!("Fetching repos from source: {}", org.url);
|
||||||
headers.insert(AUTHORIZATION, format!("token {}", config.api_key).parse()?);
|
let urls =
|
||||||
let client = reqwest::Client::builder()
|
fetch_external_org_repos(&http_client, &org.url, org.api_key.as_deref()).await?;
|
||||||
.default_headers(headers)
|
for url in urls {
|
||||||
.build()?;
|
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);
|
// 4. Calculate Diff
|
||||||
let user = client
|
let mut to_add: Vec<(String, String)> = desired_repos
|
||||||
.get(&user_url)
|
.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('/').next_back().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()
|
.send()
|
||||||
.await?
|
.await?
|
||||||
.error_for_status()?
|
.error_for_status()?
|
||||||
.json::<GiteaUser>()
|
.json()
|
||||||
.await
|
.await?;
|
||||||
.context("Failed to get Gitea user info. Check your API key and Gitea URL.")?;
|
Ok(user.login)
|
||||||
info!("Authenticated as user '{}' (ID: {})", user.login, user.id);
|
}
|
||||||
|
|
||||||
// **MODIFIED**: We now build two sets: one for source URLs and one for existing repo names.
|
/// Fetches ALL repos for the target owner on the Gitea instance.
|
||||||
info!("🔍 Fetching all existing repositories to build a local cache...");
|
async fn fetch_all_target_repos(
|
||||||
let mut existing_mirror_sources: HashSet<String> = HashSet::new();
|
client: &reqwest::Client,
|
||||||
let mut existing_repo_names: HashSet<String> = HashSet::new();
|
gitea_url: &str,
|
||||||
|
api_key: &str,
|
||||||
|
owner: &str,
|
||||||
|
) -> Result<Vec<String>, Box<dyn std::error::Error>> {
|
||||||
|
let org_url = format!("{}/api/v1/orgs/{}/repos", gitea_url, owner);
|
||||||
|
match fetch_repos_from_endpoint(client, &org_url, api_key).await {
|
||||||
|
Ok(repos) => Ok(repos),
|
||||||
|
Err(e) => {
|
||||||
|
if e.downcast_ref::<reqwest::Error>()
|
||||||
|
.is_some_and(|r| r.status() == Some(reqwest::StatusCode::NOT_FOUND))
|
||||||
|
{
|
||||||
|
info!("Owner '{}' not found as org, trying as user...", owner);
|
||||||
|
let user_url = format!("{}/api/v1/users/{}/repos", gitea_url, owner);
|
||||||
|
return fetch_repos_from_endpoint(client, &user_url, api_key).await;
|
||||||
|
}
|
||||||
|
Err(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
let mut page = 1;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let repos_url = format!("{}/api/v1/user/repos", config.gitea_url);
|
let params = [("limit", "50"), ("page", &page.to_string())];
|
||||||
let repos_on_page = client
|
|
||||||
.get(&repos_url)
|
let res = client
|
||||||
.query(&[("limit", "50"), ("page", &page.to_string())])
|
.get(url)
|
||||||
|
.bearer_auth(api_key)
|
||||||
|
.query(¶ms)
|
||||||
.send()
|
.send()
|
||||||
.await?
|
.await?
|
||||||
.error_for_status()?
|
.error_for_status()?;
|
||||||
.json::<Vec<GiteaRepo>>()
|
|
||||||
.await
|
|
||||||
.context("Failed to fetch a page of existing repositories.")?;
|
|
||||||
|
|
||||||
if repos_on_page.is_empty() {
|
let json: serde_json::Value = res.json().await?;
|
||||||
|
let data = json.as_array().ok_or("Invalid API response")?;
|
||||||
|
|
||||||
|
if data.is_empty() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
for repo in repos_on_page {
|
for repo in data {
|
||||||
// Add the name of EVERY repo to prevent any name collisions.
|
if let Some(name) = repo.get("name").and_then(|n| n.as_str()) {
|
||||||
existing_repo_names.insert(repo.name);
|
names.push(name.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
page += 1;
|
||||||
|
}
|
||||||
|
Ok(names)
|
||||||
|
}
|
||||||
|
|
||||||
// If it's a mirror, store its ORIGINAL source URL for an exact match.
|
/// Fetches clone URLs from external source (GitHub/Gitea).
|
||||||
if repo.mirror {
|
async fn fetch_external_org_repos(
|
||||||
if let Some(mirror_url) = repo.mirror_url {
|
client: &reqwest::Client,
|
||||||
existing_mirror_sources.insert(mirror_url);
|
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, "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;
|
page += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
info!(
|
Ok(repos)
|
||||||
"Found {} existing repositories and {} configured mirrors.",
|
}
|
||||||
existing_repo_names.len(),
|
|
||||||
existing_mirror_sources.len()
|
|
||||||
);
|
|
||||||
|
|
||||||
// **MODIFIED**: The main checking logic is now much more robust.
|
async fn create_migration(
|
||||||
for repo_config in &config.repos {
|
client: &reqwest::Client,
|
||||||
let url_to_mirror = &repo_config.url;
|
gitea_url: &str,
|
||||||
|
api_key: &str,
|
||||||
// CHECK 1: Has this exact source URL already been mirrored?
|
payload: &MigrateRepoPayload<'_>,
|
||||||
if existing_mirror_sources.contains(url_to_mirror) {
|
) -> Result<(), reqwest::Error> {
|
||||||
info!(
|
let url = format!("{}/api/v1/repos/migrate", gitea_url);
|
||||||
"Mirror for source URL '{}' already exists. Skipping.",
|
client
|
||||||
url_to_mirror
|
.post(&url)
|
||||||
);
|
.bearer_auth(api_key)
|
||||||
continue;
|
.json(payload)
|
||||||
}
|
.send()
|
||||||
|
.await?
|
||||||
// Determine the target name for the new repository.
|
.error_for_status()?;
|
||||||
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.");
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_repo_name_from_url(git_url: &str) -> Option<String> {
|
async fn delete_repo(
|
||||||
Url::parse(git_url)
|
client: &reqwest::Client,
|
||||||
.ok()
|
gitea_url: &str,
|
||||||
.and_then(|url| url.path_segments()?.last().map(|s| s.to_string()))
|
api_key: &str,
|
||||||
.map(|name| name.strip_suffix(".git").unwrap_or(&name).to_string())
|
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