Metadata-Version: 2.4
Name: hydra-repo-syncer
Version: 0.5.1
Summary: Provision a repo across self-hosted GitLab, GitLab.com, and GitHub with push mirroring.
Author: aanand
License: MIT
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: requests>=2.31
Requires-Dist: urllib3>=1.26
Requires-Dist: typer>=0.12
Requires-Dist: pyyaml>=6.0
Requires-Dist: keyring>=24.0
Requires-Dist: python-dotenv>=1.0
Requires-Dist: questionary>=2.0
Requires-Dist: rich>=13.0
Provides-Extra: dev
Requires-Dist: pytest>=8.0; extra == "dev"
Requires-Dist: pytest-cov>=5.0; extra == "dev"
Requires-Dist: pytest-xdist>=3.5; extra == "dev"
Requires-Dist: requests-mock>=1.11; extra == "dev"
Requires-Dist: ruff>=0.6; extra == "dev"
Dynamic: license-file

<p align="center">
  <img src="images/hydra-2.png" alt="Hydra" width="180">
</p>

> **One source, many mirrors.** Provision a single repo on self-hosted GitLab, GitLab.com, and GitHub in one shot — with push mirroring wired up so every push fans out automatically.

Hydra is a small Python CLI for teams who keep code on a **self-hosted GitLab** but also need it on **GitLab.com** and/or **GitHub** — for open-source releases, customer access, vendor integrations, or backup. You run one command and Hydra creates the project on all three hosts, then configures GitLab's built-in push mirrors so the self-hosted copy is the only place you ever push.

```
                                       ┌──────────────────┐
                                  ┌──▶ │   GitLab.com     │
                                  │    └──────────────────┘
   ┌────────────────────────┐  push
   │  Self-hosted GitLab    │ ──┤
   │  (source of truth)     │  push
   └────────────────────────┘    │    ┌──────────────────┐
            ▲                    └──▶ │     GitHub       │
            │                         └──────────────────┘
       git push (you)
```

---

## Requirements

- **Python 3.9 or newer**
- Permission to create projects/repos on each host you want to use (self-hosted GitLab, GitLab.com, GitHub)
- A personal access token for each host (Hydra tells you exactly which scopes during setup — see [Token scopes](#token-scopes))

---

## Install

From PyPI:

```sh
pip install hydra-repo-syncer
hydra --version
```

From source (if you'd rather pin to a checkout, or want to hack on Hydra itself):

```sh
git clone <this-repo-url>
cd hydra
python -m venv venv && source venv/bin/activate
pip install -e .
hydra --version
```

After installing, the `hydra` command is on your `PATH`.

---

## Quickstart

```sh
# 1. One-time setup — pick hosts, defaults, and store tokens
hydra configure

# (optional) shell tab-completion for bash/zsh/fish
hydra --install-completion

# 2. See what *would* happen, without making any API calls
hydra create my-first-repo --dry-run

# 3. Do it for real
hydra create my-first-repo
```

That's it. The repo now exists on all three hosts, and any future `git push` to the self-hosted GitLab will mirror automatically to the other two.

---

## Configure (one-time)

```sh
hydra configure
```

A four-step wizard walks you through:

| Step | What you provide |
| ---- | ---------------- |
| 1. Hosts          | URLs for self-hosted GitLab, GitLab.com, and GitHub |
| 2. GitHub account | Your GitHub user, or an organisation name |
| 3. Defaults       | Default group path; default visibility (private/public) |
| 4. Tokens         | API tokens for each host, plus where to store them |

Non-secret settings are saved to `~/.config/hydra/config.yaml`. **Tokens go to your OS keyring** (macOS Keychain, Linux Secret Service) — never to the YAML.

### Token scopes

When you mint personal access tokens, use these scopes:

| Host | Required scope | Mint a token at |
| ---- | -------------- | --------------- |
| Self-hosted GitLab | `api` | `<your-host>/-/user_settings/personal_access_tokens` |
| GitLab.com         | `api` | https://gitlab.com/-/user_settings/personal_access_tokens |
| GitHub             | `repo` (plus `admin:org` if creating under an organisation). To delete GitHub repos with `hydra destroy`, also grant `delete_repo` for classic PATs or `Administration: Read and write` for fine-grained PATs. | https://github.com/settings/tokens |

### Token resolution order

For each host, Hydra looks up the token in this order and stops at the first hit:

1. **OS keyring** — set via `hydra configure`, or directly: `keyring set hydra <github|gitlab|self_hosted_gitlab>`
2. **Environment variable** — `HYDRA_GITHUB_TOKEN`, `HYDRA_GITLAB_TOKEN`, `HYDRA_SELF_HOSTED_GITLAB_TOKEN`
3. **`.env` file** in the current working directory (see `.env.example`)
4. **Interactive prompt** (only if attached to a TTY)

This lets you use the keyring on your laptop and env vars in CI without changing anything else.

---

## Creating repos

Two modes — interactive wizard (good for one-offs), or flag-driven (good for scripts).

### Interactive

```sh
hydra create
```

The wizard collects the repo name, description, group, visibility, GitHub destination, and mirror toggle, shows a review summary, then asks you to **create now**, **dry-run**, or **cancel**.

### Flags

```sh
# Dry-run — recommended for the first try; renders the plan, no API calls
hydra create my-repo -d "demo" -g platform/services --dry-run

# Real run — renders the plan first, then prompts y/N before any mutation
hydra create my-repo -d "demo" -g platform/services

# Skip the prompt (useful in CI / scripts)
hydra create my-repo -d "demo" -g platform/services --yes

# Public repo, under a GitHub org, skip mirror setup
hydra create my-repo --public --host-option github.org=acme --no-mirror
```

Omit the name to launch the wizard; pass a name to stay in flag mode.

Every mutating run starts by printing the **plan** — the ordered list of
namespaces / repos / mirrors / journal entries that would be created. With
`--dry-run` it stops there. Without it, you get one confirmation prompt
before any provider call. `--yes` skips the prompt.

| Flag | Meaning |
| ---- | ------- |
| `-d`, `--description`   | Repo description |
| `-g`, `--group`         | Group path on self-hosted GitLab |
| `--public`              | Create as public (default is private) |
| `--host-option <id.k=v>`| Per-host override, e.g. `github.org=acme` |
| `--no-mirror`           | Skip push-mirror setup |
| `--dry-run`             | Print the plan and exit; no API calls |
| `-y`, `--yes`           | Skip the confirmation prompt |
| `--config <path>`       | Use a non-default config file |
| `-v`, `--verbose`       | Print extra detail (group IDs, etc.) |

---

## Destroying repos

```sh
# Preview the repo/fork cleanup plan, then confirm
hydra destroy my-repo

# Skip the confirmation prompt
hydra destroy my-repo --yes

# Also delete inferred GitLab groups/namespaces after repos are deleted
hydra destroy my-repo --delete-group
```

`hydra destroy <name>` reads the local journal, deletes fork repos first, then
the primary repo, and removes the journal row after successful cleanup. If the
journal is incomplete because an earlier `hydra create` failed before mirror
setup, Hydra probes configured fork hosts for orphaned repos by name and includes
anything it finds in the plan.

Group deletion is deliberately opt-in. `--delete-group` (alias:
`--delete-namespace`) infers GitLab namespaces from repo URLs and deletes those
namespaces after repo deletion. Use it only for groups Hydra created or groups
you know are safe to remove.

GitLab project deletion is asynchronous. If a retry sees that GitLab has already
marked a project for deletion, Hydra treats that as success and continues. If a
delete fails because of permissions, the journal row is preserved so you can fix
the token and rerun the same command.

---

## Inspecting mirrors

```sh
hydra status my-repo            # offline — reads the journal cache
hydra status my-repo --refresh  # re-query the primary, then show
```

Shows per-mirror last status and last error inline for one repo, straight from the journal — no network unless you pass `--refresh`. Exits non-zero if any mirror is unhealthy, so it doubles as a CI health gate. When a mirror is broken, `hydra repair` re-establishes it without a full `scan`.

---

## Commands

| Command | Description |
| ------- | ----------- |
| `hydra create [name]` | Create the repo across all three hosts. Without `name`, runs the wizard. Renders a plan + prompts before applying (skip with `--yes`). |
| `hydra destroy <name>`| Delete a journaled repo and its forks. Probes for orphaned forks, deletes forks before the primary, and can also remove inferred GitLab groups with `--delete-group`. |
| `hydra configure`     | Onboarding wizard — config + tokens. |
| `hydra status <name>` | Per-mirror health for one repo from the journal (offline). `--refresh` re-queries the primary first. Exits non-zero if any mirror is unhealthy. |
| `hydra list`          | List journaled repos and last-known mirror status. `--refresh` re-queries the primary (uses `--max-workers`, default 8). |
| `hydra scan`          | Diff the journal against the primary. `--apply` adopts unknowns and resyncs drifted ids (renders a plan + prompts; skip with `--yes`). `--interactive` filters the plan per-repo first. `--max-workers <N>` controls concurrent HTTP calls (default 8, env `HYDRA_SCAN_WORKERS`). |
| `hydra repair [name]` | Re-establish mirrors the journal marks unhealthy (broken/missing/failed/error): re-adds gone mirrors, replaces failing ones. Renders a plan + prompts (skip with `--yes`); supports `--dry-run` and `--host <id>`. |
| `hydra rotate-token`  | Rotate a host PAT in the keyring and rewrite every push-mirror that embeds the old token. |
| `hydra doctor`        | Diagnose configuration, tokens, and topology. `--fix` runs safe migrations. |
| `hydra config-path`   | Print the resolved config-file path. |
| `hydra journal-path`  | Print the resolved journal database path. |

Run `hydra <cmd> --help` for full flags.

---

## Error handling

Hydra translates HTTP failures into actionable messages:

```
✗ GitLab.com authentication failed (401) while searching for group 'platform/services'

  The GitLab.com token was rejected. Rotate it at
  https://gitlab.com/-/user_settings/personal_access_tokens
  and re-run `hydra configure`, or set HYDRA_GITLAB_TOKEN in your environment.
```

If a failure happens **after** some resources have been created, the partial state is reported and Hydra offers to roll those resources back immediately:

```
⚠ Partial progress before the failure:
  • self-hosted GitLab repo: https://gitlab.example.com/sandbox/demo
  • gitlab.com group: https://gitlab.com/repo-syncer-managed-groups/sandbox-20260508131245

  These resources exist now.

  Roll back the created resources? [y/N]:
```

If you decline rollback or the process is interrupted, rerun cleanup later with
`hydra destroy <name>`. Add `--delete-group` if Hydra created GitLab groups that
should be removed too.

---

## Config file

Lives at `~/.config/hydra/config.yaml` by default. Override with `--config <path>` or the `HYDRA_CONFIG` environment variable. See [`config.yaml.example`](./config.yaml.example) for the full schema:

```yaml
self_hosted_gitlab:
  url: https://gitlab.example.com

gitlab:
  url: https://gitlab.com
  managed_group_prefix: repo-syncer-managed-groups

github:
  url: https://api.github.com
  org: null         # null = create under your user; or set an org name

defaults:
  private: true
  group: ""         # optional default group path on the self-hosted GitLab
```

---

## Security notes

- Tokens are **never** written to the YAML config.
- Tokens injected into mirror URLs (`https://oauth2:<token>@host/...`) are stored on the self-hosted GitLab's `remote_mirrors` table. Anyone with project admin access can read them back via the GitLab API — use **scoped** tokens.
- Keep `.env` gitignored. It already is in this repo.

---

## Development

Clone the repo and install with the `dev` extras:

```sh
git clone <this-repo-url>
cd hydra
python -m venv venv && source venv/bin/activate
pip install -e '.[dev]'
pytest
```

Unit tests cover error translation, slug generation, wizard validators, and credential injection. CI runs the same suite plus a `hydra --help` smoke test on every push (`.gitlab-ci.yml`).

---

## License

MIT. See [`LICENSE`](./LICENSE).
