{ den.aspects.gitea-mirrors = { nixos = { pkgs, lib, config, ... }: let cfg = config.services.gitea; mcfg = cfg.mirrors; uniqueOwners = lib.unique ( (map (m: m.owner) mcfg.repos) ++ (map (m: m.owner) mcfg.users) ); giteaUrl = "http://localhost:${toString cfg.settings.server.HTTP_PORT}"; tokenFile = "${cfg.stateDir}/mirror-setup-token"; repoSource = m: if m ? source then m.source else if (m.service or "git") == "github" then "https://github.com/${m.owner}/${m.repo}.git" else if (m.service or "git") == "gitlab" then "https://gitlab.com/${m.owner}/${m.repo}.git" else throw "mirrors.repos: '${m.owner}/${m.repo}' has no source and service '${m.service or "git"}' needs an explicit source"; setupScript = pkgs.writeShellScript "gitea-mirror-setup" '' set -euo pipefail GITEA_URL="${giteaUrl}" TOKEN_FILE="${tokenFile}" GITEA_CMD="${lib.getExe cfg.package}" for i in $(seq 1 60); do if ${pkgs.curl}/bin/curl -sf "$GITEA_URL/api/v1/version" >/dev/null 2>&1; then break fi sleep 2 done $GITEA_CMD admin user create \ --username "${mcfg.admin.user}" \ --email "${mcfg.admin.email}" \ --random-password \ --admin \ --must-change-password=false 2>/dev/null || true if [ ! -f "$TOKEN_FILE" ]; then TOKEN=$($GITEA_CMD admin user generate-access-token \ --username "${mcfg.admin.user}" \ --token-name "mirror-setup" \ --scopes "all" \ --raw) echo "$TOKEN" > "$TOKEN_FILE" chmod 600 "$TOKEN_FILE" fi TOKEN=$(cat "$TOKEN_FILE") AUTH="Authorization: token $TOKEN" ${lib.concatMapStringsSep "\n" (owner: '' RAND_PASS=$(${pkgs.openssl}/bin/openssl rand -base64 32) ${pkgs.curl}/bin/curl -sf -X POST "$GITEA_URL/api/v1/admin/users" \ -H "$AUTH" \ -H "Content-Type: application/json" \ -d "{ \"username\": \"${owner}\", \"email\": \"${owner}@git.bug.tools\", \"password\": \"$RAND_PASS\", \"must_change_password\": false, \"visibility\": \"public\" }" >/dev/null 2>&1 || true '') uniqueOwners} ${lib.concatMapStringsSep "\n" (m: '' ${pkgs.curl}/bin/curl -sf -X POST "$GITEA_URL/api/v1/repos/migrate" \ -H "$AUTH" \ -H "Content-Type: application/json" \ -d '{ "clone_addr": "${repoSource m}", "repo_name": "${m.repo}", "repo_owner": "${m.owner}", "mirror": true, "private": false, "service": "${m.service or "git"}" }' >/dev/null 2>&1 || true '') mcfg.repos} ${lib.concatMapStringsSep "\n" (u: let platform = u.platform; owner = u.owner; baseUrl = u.baseUrl or (if platform == "github" then "https://api.github.com" else if platform == "gitlab" then "https://gitlab.com" else if platform == "gitea" then u.baseUrl else throw "mirrors.users: unsupported platform '${platform}', provide baseUrl"); cloneBase = if platform == "github" then "https://github.com/${owner}" else if platform == "gitlab" then "${u.baseUrl or "https://gitlab.com"}/${owner}" else if platform == "gitea" then "${u.baseUrl}/${owner}" else throw "mirrors.users: unsupported platform '${platform}'"; service = if platform == "github" then "github" else if platform == "gitlab" then "gitlab" else if platform == "gitea" then "gitea" else "git"; jqExpr = if platform == "gitlab" then ".[].path" else ".[].name"; pageSize = if platform == "gitea" then 50 else 100; pageSizeStr = toString pageSize; listUrl = if platform == "github" then "${baseUrl}/users/${owner}/repos?per_page=${pageSizeStr}&type=owner" else if platform == "gitlab" then "${baseUrl}/api/v4/users/${owner}/projects?per_page=${pageSizeStr}" else "${baseUrl}/api/v1/users/${owner}/repos?limit=${pageSizeStr}"; in '' echo "Fetching repos for ${owner} from ${platform}..." PAGE=1 while true; do REPOS=$(${pkgs.curl}/bin/curl -sf "${listUrl}&page=$PAGE" || echo "[]") NAMES=$(echo "$REPOS" | ${pkgs.jq}/bin/jq -r '${jqExpr}') if [ -z "$NAMES" ] || [ "$NAMES" = "null" ]; then break fi for REPO_NAME in $NAMES; do ${pkgs.curl}/bin/curl -sf -X POST "$GITEA_URL/api/v1/repos/migrate" \ -H "$AUTH" \ -H "Content-Type: application/json" \ -d "{ \"clone_addr\": \"${cloneBase}/$REPO_NAME.git\", \"repo_name\": \"$REPO_NAME\", \"repo_owner\": \"${owner}\", \"mirror\": true, \"private\": false, \"service\": \"${service}\" }" >/dev/null 2>&1 || true done COUNT=$(echo "$REPOS" | ${pkgs.jq}/bin/jq 'length') if [ "$COUNT" -lt ${pageSizeStr} ]; then break; fi PAGE=$((PAGE + 1)) done '') mcfg.users} echo "Mirror setup complete." ''; in { options.services.gitea.mirrors = { admin = { user = lib.mkOption { type = lib.types.str; default = "admin"; description = "Gitea admin username for mirror management."; }; email = lib.mkOption { type = lib.types.str; default = "admin@localhost"; description = "Gitea admin email."; }; }; repos = lib.mkOption { type = lib.types.listOf (lib.types.attrsOf lib.types.str); default = []; description = "Individual repositories to mirror. Each entry: { owner, repo, service?, source? }. Source is auto-derived for github/gitlab."; example = [ { owner = "nixos"; repo = "nixpkgs"; service = "github"; } { owner = "someone"; repo = "thing"; source = "https://custom.instance/someone/thing.git"; } ]; }; users = lib.mkOption { type = lib.types.listOf (lib.types.attrsOf lib.types.str); default = []; description = "Mirror all public repos of a user/org. Each entry: { owner, platform, baseUrl? }."; example = [ { owner = "catppuccin"; platform = "github"; } ]; }; }; config = lib.mkIf (mcfg.repos != [] || mcfg.users != []) { services.gitea.settings.mirror = { DEFAULT_INTERVAL = "6h"; MIN_INTERVAL = "10m"; }; services.gitea.settings."cron.update_mirrors" = { SCHEDULE = "@every 10m"; RUN_AT_START = true; }; systemd.services.gitea-mirror-setup = { description = "Setup Gitea mirror repositories"; after = [ "gitea.service" ]; requires = [ "gitea.service" ]; serviceConfig = { Type = "oneshot"; User = cfg.user; Group = cfg.group; ExecStart = setupScript; WorkingDirectory = cfg.stateDir; Environment = [ "GITEA_WORK_DIR=${cfg.stateDir}" "GITEA_CUSTOM=${cfg.customDir}" ]; }; }; systemd.timers.gitea-mirror-setup = { description = "Run Gitea mirror setup on boot and periodically"; wantedBy = [ "timers.target" ]; timerConfig = { OnBootSec = "2min"; OnUnitActiveSec = "6h"; Unit = "gitea-mirror-setup.service"; }; }; }; }; }; }