diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c754aea --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,65 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + name: Test + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: '1.26' + + - name: Download dependencies + run: go mod download + + - name: Run tests + run: go test -v -race -coverprofile=coverage.out ./... + + - name: Upload coverage + uses: codecov/codecov-action@v4 + with: + files: coverage.out + + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: '1.26' + + - name: golangci-lint + uses: golangci/golangci-lint-action@v7 + with: + version: v2.11.3 + + build-docker: + name: Build Docker image (test) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build + uses: docker/build-push-action@v5 + with: + context: . + push: false + tags: gitmeout:test \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..2df82b9 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,132 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + packages: write + +jobs: + build-binaries: + name: Build binaries + runs-on: ubuntu-latest + strategy: + matrix: + goos: [linux, darwin, windows] + goarch: [amd64, arm64] + exclude: + - goos: windows + goarch: arm64 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: '1.26' + + - name: Build binary + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + CGO_ENABLED: 0 + run: | + VERSION=${GITHUB_REF_NAME#v} + BINARY_NAME=gitmeout + if [ "$GOOS" = "windows" ]; then + BINARY_NAME=gitmeout.exe + fi + go build -ldflags="-s -w -X main.version=${VERSION}" -o bin/${GOOS}_${GOARCH}/${BINARY_NAME} ./cmd/gitmeout + + - name: Create archive + run: | + cd bin/${{ matrix.goos }}_${{ matrix.goarch }} + if [ "${{ matrix.goos }}" = "windows" ]; then + zip -r ../../gitmeout-${{ matrix.goos }}-${{ matrix.goarch }}.zip . + else + tar -czvf ../../gitmeout-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz . + fi + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: gitmeout-${{ matrix.goos }}-${{ matrix.goarch }} + path: | + gitmeout-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz + gitmeout-${{ matrix.goos }}-${{ matrix.goarch }}.zip + if-no-files-found: ignore + + build-docker: + name: Build Docker image + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Login to Forgejo Container Registry + uses: docker/login-action@v3 + with: + registry: code.decastro.me + username: ${{ secrets.FORGEJO_REGISTRY_USER }} + password: ${{ secrets.FORGEJO_REGISTRY_TOKEN }} + + - name: Get version + id: version + run: echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: | + ghcr.io/${{ github.repository_owner }}/gitmeout:latest + ghcr.io/${{ github.repository_owner }}/gitmeout:${{ steps.version.outputs.VERSION }} + code.decastro.me/guilherme/gitmeout:latest + code.decastro.me/guilherme/gitmeout:${{ steps.version.outputs.VERSION }} + build-args: | + VERSION=${{ steps.version.outputs.VERSION }} + + release: + name: Create release + needs: [build-binaries, build-docker] + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Download artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + pattern: gitmeout-* + merge-multiple: true + + - name: List artifacts + run: ls -la artifacts/ + + - name: Create release + uses: softprops/action-gh-release@v1 + with: + files: | + artifacts/*.tar.gz + artifacts/*.zip + generate_release_notes: true \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..328e2a9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,49 @@ +# Binaries +*.exe +*.exe~ +*.dll +*.so +*.dylib +bin/ + +# Test binary +*.test + +# Output of go coverage +*.out +coverage.txt +coverage.html + +# Go workspace file +go.work +go.work.sum + +# Dependency directories +vendor/ + +# Build artifacts +dist/ +.build/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Secrets and config +.env +.env.* +!.env.example +config.local.yaml +*.local.yaml + +# Debug +debug +__debug_bin* +config.yaml diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..5dd0968 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,9 @@ +version: "2" + +linters: + enable: + - errcheck + - govet + - ineffassign + - staticcheck + - unused \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..35ec616 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,225 @@ +# Agent Guidelines for gitmeout + +## Overview + +`gitmeout` is a Go CLI tool that mirrors repositories from a source Git provider (GitHub) to target Forgejo/Codeberg instances. It runs periodically via external schedulers (cron, Kubernetes CronJobs). + +--- + +## Build, Lint, and Test Commands + +### Using Just (recommended) + +```bash +just build # Build binary to bin/gitmeout +just run # Run the application +just test # Run all tests +just test-verbose # Run tests with verbose output +just test-coverage # Run tests with coverage report +just lint # Run golangci-lint +just fmt # Format code with gofmt +just vet # Run go vet +just tidy # Tidy go.mod +just docker-build # Build Docker image +just docker-run # Run Docker container locally +``` + +### Direct Go Commands + +```bash +go build -o bin/gitmeout ./cmd/gitmeout +go run ./cmd/gitmeout +go test ./... +go test -v ./... +go test -coverprofile=coverage.out ./... +go tool cover -html=coverage.out +golangci-lint run +gofmt -w . +go vet ./... +go mod tidy +``` + +### Running a Single Test + +```bash +# Run a single test file +go test -v ./internal/config/... + +# Run a specific test by name +go test -v -run TestConfig_Parse ./internal/config/... + +# Run tests in a specific package +go test -v ./internal/mirror/... +``` + +--- + +## Project Structure + +``` +gitmeout/ +├── cmd/ +│ └── gitmeout/ +│ └── main.go # Entry point +├── internal/ +│ ├── config/ +│ │ ├── config.go # Config parsing and validation +│ │ └── config_test.go +│ ├── source/ +│ │ └── github/ +│ │ └── client.go # GitHub API client +│ ├── target/ +│ │ └── forgejo/ +│ │ └── client.go # Forgejo API client +│ └── mirror/ +│ ├── service.go # Core mirroring logic +│ └── service_test.go +├── deploy/ +│ └── k8s/ +│ ├── job.yaml # K8s Job CR +│ └── cronjob.yaml # K8s CronJob example +├── go.mod +├── go.sum +├── Justfile +├── Dockerfile +└── README.md +``` + +--- + +## Code Style Guidelines + +### General Principles + +- Follow standard Go conventions (Effective Go) +- Use `gofmt` for formatting +- Keep functions small and focused +- Prefer composition over inheritance +- Handle errors explicitly + +### Naming Conventions + +- **Packages**: lowercase, single word (e.g., `config`, `mirror`) +- **Types**: PascalCase for exported, camelCase for unexported +- **Functions/Methods**: PascalCase for exported, camelCase for unexported +- **Constants**: PascalCase or UPPER_SNAKE_CASE for exported constants +- **Interfaces**: typically end with `-er` (e.g., `RepositoryLister`, `Mirrorer`) + +### Error Handling + +- Always handle errors explicitly +- Wrap errors with context using `fmt.Errorf("operation failed: %w", err)` +- Use custom error types for domain-specific errors +- Never panic in library code + +```go +func (s *Service) Mirror(ctx context.Context, repo *Repository) error { + if err := s.target.CreateMirror(ctx, repo); err != nil { + return fmt.Errorf("failed to create mirror for %s: %w", repo.Name, err) + } + return nil +} +``` + +### Logging + +- Use structured logging (slog package from Go 1.21+) +- Log at appropriate levels: Debug, Info, Warn, Error +- Include relevant context in log entries + +```go +logger.Info("mirroring repository", "repo", repo.Name, "target", target.Name) +``` + +### Testing + +- Place tests in the same package with `_test.go` suffix +- Use table-driven tests for multiple test cases +- Use `t.Parallel()` where appropriate +- Aim for high coverage on core logic + +```go +func TestParseConfig(t *testing.T) { + tests := []struct { + name string + input string + want *Config + wantErr bool + }{ + {name: "valid config", input: validYAML, want: validConfig, wantErr: false}, + {name: "missing token", input: missingTokenYAML, want: nil, wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseConfig(strings.NewReader(tt.input)) + if (err != nil) != tt.wantErr { + t.Errorf("ParseConfig() error = %v, wantErr %v", err, tt.wantErr) + } + if !cmp.Equal(got, tt.want) { + t.Errorf("ParseConfig() = %v, want %v", got, cmp.Diff(got, tt.want)) + } + }) + } +} +``` + +### Imports + +Group imports in this order: +1. Standard library +2. External packages +3. Internal packages + +```go +import ( + "context" + "fmt" + + "github.com/google/go-github/v69/github" + "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v3" + + "github.com/guilycst/gitmeout/internal/config" +) +``` + +--- + +## Configuration + +- Config file: `config.yaml` (mounted as secret in K8s) +- Environment variable interpolation: `${VAR_NAME}` syntax +- Required: source token, all target tokens + +--- + +## Git Workflow + +### Commit Messages + +Follow conventional commits: + +``` +(): + +Types: feat, fix, docs, style, refactor, test, chore + +Examples: +feat(source): add support for filtering by org membership +fix(mirror): skip existing mirrors instead of failing +docs(readme): update installation instructions +``` + +### Branch Naming + +- Use kebab-case +- Prefix: `feature/`, `fix/`, `chore/` +- Example: `feature/add-gitlab-source` + +--- + +## Additional Notes + +- Run `just lint` and `just test` before submitting changes +- Ensure `gofmt` has been run on all Go files +- Update go.mod when adding dependencies (`go mod tidy`) +- Keep the README.md up to date with usage examples diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5cf4b3a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,29 @@ +ARG VERSION=dev + +FROM golang:1.26-alpine AS builder + +RUN apk add --no-cache git ca-certificates + +WORKDIR /app + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +ARG VERSION +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w -X main.version=${VERSION}" -o /gitmeout ./cmd/gitmeout + +FROM alpine:3.20 + +RUN apk add --no-cache ca-certificates tzdata git + +RUN adduser -D -g '' appuser + +WORKDIR /app + +COPY --from=builder /gitmeout /app/gitmeout + +USER appuser + +ENTRYPOINT ["/app/gitmeout"] \ No newline at end of file diff --git a/Justfile b/Justfile new file mode 100644 index 0000000..4501676 --- /dev/null +++ b/Justfile @@ -0,0 +1,80 @@ +app := "gitmeout" +version := "dev" +ldflags := "-s -w -X main.version=" + version + +build: + go build -ldflags "{{ldflags}}" -o bin/{{app}} ./cmd/{{app}} + +run: build + ./bin/{{app}} + +test: + go test ./... + +test-verbose: + go test -v ./... + +test-coverage: + go test -coverprofile=coverage.out ./... + go tool cover -html=coverage.out -o coverage.html + echo "Coverage report: coverage.html" + +lint: + golangci-lint run + +fmt: + gofmt -w . + +vet: + go vet ./... + +tidy: + go mod tidy + +validate: fmt vet lint test + echo "All checks passed!" + +clean: + rm -rf bin/ + rm -f coverage.out coverage.html + +docker-build: + docker build -t {{app}}:{{version}} . + +docker-run: + docker run --rm -v $(pwd)/config.yaml:/app/config.yaml:ro {{app}}:{{version}} + +docker-push *VERSION: + #!/usr/bin/env bash + version="{{VERSION}}" + if [ -z "$version" ]; then + version="{{version}}" + fi + docker build --build-arg VERSION=$version -t code.decastro.me/guilherme/gitmeout:$version -t code.decastro.me/guilherme/gitmeout:latest . + docker push code.decastro.me/guilherme/gitmeout:$version + docker push code.decastro.me/guilherme/gitmeout:latest + +docker-push-ghcr *VERSION: + #!/usr/bin/env bash + version="{{VERSION}}" + if [ -z "$version" ]; then + version="{{version}}" + fi + docker build --build-arg VERSION=$version -t ghcr.io/guilycst/gitmeout:$version -t ghcr.io/guilycst/gitmeout:latest . + docker push ghcr.io/guilycst/gitmeout:$version + docker push ghcr.io/guilycst/gitmeout:latest + +release *VERSION: + #!/usr/bin/env bash + version="{{VERSION}}" + if [ -z "$version" ]; then + echo "Usage: just release " + exit 1 + fi + git tag -a v$version -m "Release v$version" + git push origin v$version + +install: build + cp bin/{{app}} /usr/local/bin/{{app}} + +.PHONY: build run test test-verbose test-coverage lint fmt vet tidy validate clean docker-build docker-run docker-push release install diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5e23cdd --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Guilherme de Castro + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 235970f..9d7d7f8 100644 --- a/README.md +++ b/README.md @@ -1 +1,220 @@ # gitmeout + +[![CI](https://github.com/guilycst/gitmeout/actions/workflows/ci.yml/badge.svg)](https://github.com/guilycst/gitmeout/actions/workflows/ci.yml) +[![Release](https://github.com/guilycst/gitmeout/actions/workflows/release.yml/badge.svg)](https://github.com/guilycst/gitmeout/actions/workflows/release.yml) +[![Go Report Card](https://goreportcard.com/badge/github.com/guilycst/gitmeout)](https://goreportcard.com/report/github.com/guilycst/gitmeout) +[![GoDoc](https://godoc.org/github.com/guilycst/gitmeout?status.svg)](https://godoc.org/github.com/guilycst/gitmeout) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![Go Version](https://img.shields.io/badge/Go-1.26-00ADD8?logo=go)](https://go.dev/) + +**Container Images:** +[![GHCR](https://img.shields.io/badge/ghcr.io-guilycst/gitmeout-blue)](https://github.com/guilycst/gitmeout/pkgs/container/gitmeout) +[![Forgejo](https://img.shields.io/badge/code.decastro.me-guilherme/gitmeout-blue)](https://code.decastro.me/guilherme/-/packages/container/gitmeout) + +> Mirror GitHub repositories to Forgejo/Codeberg instances + +A fast, reliable CLI tool for mirroring repositories from GitHub to Forgejo/Codeberg. Supports both push and pull mirror modes, with flexible filtering and container-ready deployment. + +**Topics:** `github` `forgejo` `codeberg` `mirror` `backup` `git` `golang` `cli` `kubernetes` `docker` + +## Features + +- **GitHub as source** - Mirror personal and organization repositories +- **Forgejo/Codeberg as targets** - Create mirror repos on any Forgejo instance +- **Flexible filtering** - Filter by personal repos, orgs, or explicit repo list +- **Wildcard support** - Use `owner/*` to mirror all repos from an owner +- **Dual mirror types** - Push mirrors (clone + push) or pull mirrors (Forgejo MigrateRepo API) +- **Token-only auth** - Secure authentication via Personal Access Tokens +- **Container-ready** - Docker image and Kubernetes manifests included +- **Multiarch support** - Linux amd64/arm64 binaries and Docker images + +## ⚠️ Disclaimer + +**Use this tool responsibly and only for repositories you own or have permission to mirror.** + +- **Personal Use**: This tool is designed for mirroring your own repositories or repositories you have explicit permission to mirror. +- **GitHub ToS**: Ensure your usage complies with [GitHub's Terms of Service](https://docs.github.com/en/site-policy/github-terms/github-terms-of-service), including API rate limits and acceptable use policies. +- **Rate Limits**: GitHub's API has rate limits (5,000 authenticated requests/hour). Be mindful when mirroring large numbers of repositories. +- **Data Portability**: GitHub's ToS supports user data portability for your own content. +- **No Warranty**: This software is provided "as is" without warranty of any kind. The authors are not responsible for any issues arising from its use. + +By using this tool, you agree to: +- Only mirror repositories you own or have permission to mirror +- Comply with GitHub's API Terms of Service +- Respect rate limits and not abuse the API +- Use it for legitimate data portability purposes + +## Quick Start + +### 1. Create a config file + +```yaml +source: + type: github + token: ${GITHUB_TOKEN} + filters: + personal: true + orgs: + - my-org + repos: + - owner/repo1 + - owner/* + +targets: + - name: codeberg + type: forgejo + url: https://codeberg.org + token: ${CODEBERG_TOKEN} +``` + +### 2. Set environment variables + +```bash +export GITHUB_TOKEN="ghp_xxx" +export CODEBERG_TOKEN="xxx" +``` + +### 3. Run + +```bash +# Using Just +just run + +# Or directly +go run ./cmd/gitmeout + +# Or with Docker +docker run --rm \ + -e GITHUB_TOKEN \ + -e CODEBERG_TOKEN \ + -v $(pwd)/config.yaml:/app/config.yaml:ro \ + ghcr.io/guilycst/gitmeout:latest +``` + +## Configuration + +### Source Configuration + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `type` | string | Yes | Provider type. Currently only `github` | +| `token` | string | Yes | GitHub Personal Access Token. Use `${VAR}` for env vars | +| `filters.personal` | bool | No | Mirror personal repos. Default: `true` | +| `filters.orgs` | []string | No | List of organization names to mirror | +| `filters.repos` | []string | No | Explicit repo list. Use `owner/*` for all repos from an owner | + +### Target Configuration + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `name` | string | Yes | Friendly name for logging | +| `type` | string | Yes | Provider type. Currently only `forgejo` | +| `url` | string | Yes | Base URL of the Forgejo instance | +| `token` | string | Yes | Forgejo Personal Access Token. Use `${VAR}` for env vars | +| `mirror_type` | string | No | `push` (default) or `pull` - see Mirror Types below | + +### Mirror Types + +**Push Mirrors** (`mirror_type: push`): +- Clones the repository from GitHub, then pushes `--mirror` to the target +- Syncs every run - changes on source are pushed to target +- Use for: Codeberg (pull mirrors disabled by admin) + +**Pull Mirrors** (`mirror_type: pull`): +- Uses Forgejo's MigrateRepo API to create a pull mirror +- Forgejo periodically fetches changes from the source automatically +- Use for: Self-hosted Forgejo instances where pull mirrors are enabled +- Skips if a mirror already exists + +### Environment Variable Interpolation + +Use `${VAR_NAME}` syntax in config values to reference environment variables: + +```yaml +source: + token: ${GITHUB_TOKEN} # Reads from $GITHUB_TOKEN env var +``` + +## Behavior + +### Repository Resolution + +1. **Personal repos**: If `personal: true`, fetches all repos owned by the authenticated user +2. **Organization repos**: For each org in `orgs`, fetches all repos the user can access +3. **Explicit repos**: Resolves each entry in `repos`: + - `owner/repo-name`: Specific repo + - `owner/*`: All repos from that owner + +### Mirror Creation + +- **Existing mirrors**: If a mirror repo already exists on the target, it is skipped +- **New mirrors**: Creates a new mirror repo pointing to the source + +### Required Token Permissions + +**GitHub**: +- `repo` (for private repos) +- `read:org` (if filtering by orgs) + +**Forgejo/Codeberg**: +- `read:user` - required to get authenticated user info +- `write:repository` - required to create mirror repositories + +## Deployment + +### Docker + +```bash +docker build -t gitmeout:latest . +docker run --rm \ + -e GITHUB_TOKEN \ + -e CODEBERG_TOKEN \ + -v $(pwd)/config.yaml:/app/config.yaml:ro \ + gitmeout:latest +``` + +### Kubernetes + +Create a secret for your config: + +```bash +kubectl create secret generic gitmeout-config --from-file=config.yaml=config.yaml +``` + +Apply the CronJob: + +```bash +kubectl apply -f deploy/k8s/cronjob.yaml +``` + +See `deploy/k8s/` for Job and CronJob examples. + +### Kubernetes Custom Resource + +For GitOps workflows, see `deploy/k8s/job.yaml` for a Job CR that can be used with tools like ArgoCD or Flux. + +## Development + +```bash +# Install dependencies +go mod download + +# Build +just build + +# Run tests +just test + +# Lint +just lint + +# Format +just fmt + +# Run all checks +just validate +``` + +## License + +MIT License - see [LICENSE](LICENSE) for details. diff --git a/cmd/gitmeout/main.go b/cmd/gitmeout/main.go new file mode 100644 index 0000000..e2c8ba9 --- /dev/null +++ b/cmd/gitmeout/main.go @@ -0,0 +1,103 @@ +package main + +import ( + "context" + "log/slog" + "os" + "os/signal" + "syscall" + + "github.com/guilycst/gitmeout/internal/config" + "github.com/guilycst/gitmeout/internal/mirror" + "github.com/guilycst/gitmeout/internal/source/github" + "github.com/guilycst/gitmeout/internal/target/forgejo" +) + +var version = "dev" + +func main() { + logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelInfo, + })) + slog.SetDefault(logger) + + slog.Info("starting gitmeout", "version", version) + + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer cancel() + + cfg, err := config.Load("config.yaml") + if err != nil { + slog.Error("failed to load config", "error", err) + os.Exit(1) + } + + sourceClient, err := github.NewClient(cfg.Source.Token) + if err != nil { + slog.Error("failed to create github client", "error", err) + os.Exit(1) + } + + var targets []mirror.Target + for _, t := range cfg.Targets { + client, err := forgejo.NewClient(t.URL, t.Token) + if err != nil { + slog.Error("failed to create forgejo client", "error", err, "target", t.Name) + os.Exit(1) + } + targets = append(targets, &forgejoTarget{ + name: t.Name, + client: client, + mirrorType: t.MirrorType, + }) + } + + svc := mirror.NewService(sourceClient, targets, cfg.Source.Token) + + err = svc.Run(ctx, cfg.Source.Filters) + if err != nil { + if ctx.Err() != nil { + slog.Info("mirroring cancelled by user", "signal", "SIGINT/SIGTERM") + } else { + slog.Error("mirroring failed", "error", err) + os.Exit(1) + } + return + } + + slog.Info("mirroring completed successfully") +} + +type forgejoTarget struct { + name string + client *forgejo.Client + mirrorType string +} + +func (t *forgejoTarget) Name() string { + return t.name +} + +func (t *forgejoTarget) MirrorType() string { + return t.mirrorType +} + +func (t *forgejoTarget) CreateRepo(ctx context.Context, repo mirror.Repository) error { + return t.client.CreateRepo(ctx, repo) +} + +func (t *forgejoTarget) MigrateRepo(ctx context.Context, repo mirror.Repository) error { + return t.client.MigrateRepo(ctx, repo) +} + +func (t *forgejoTarget) Exists(ctx context.Context, repo mirror.Repository) (bool, error) { + return t.client.Exists(ctx, repo) +} + +func (t *forgejoTarget) GetCloneURL(ctx context.Context, repo mirror.Repository) (string, error) { + return t.client.GetCloneURL(ctx, repo) +} + +func (t *forgejoTarget) GetAuthToken() string { + return t.client.GetAuthToken() +} diff --git a/config.yaml.example b/config.yaml.example new file mode 100644 index 0000000..236f46a --- /dev/null +++ b/config.yaml.example @@ -0,0 +1,13 @@ +source: + type: github + token: ${GITHUB_TOKEN} + filters: + personal: true + orgs: [] + repos: [] + +targets: + - name: codeberg + type: forgejo + url: https://codeberg.org + token: ${CODEBERG_TOKEN} diff --git a/deploy/k8s/cronjob.yaml b/deploy/k8s/cronjob.yaml new file mode 100644 index 0000000..055a6b7 --- /dev/null +++ b/deploy/k8s/cronjob.yaml @@ -0,0 +1,42 @@ +apiVersion: batch/v1 +kind: CronJob +metadata: + name: gitmeout + labels: + app: gitmeout +spec: + schedule: "0 */6 * * *" + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: 3 + failedJobsHistoryLimit: 3 + jobTemplate: + spec: + template: + metadata: + labels: + app: gitmeout + spec: + restartPolicy: Never + containers: + - name: gitmeout + image: ghcr.io/guilycst/gitmeout:latest + imagePullPolicy: Always + volumeMounts: + - name: config + mountPath: /app/config.yaml + subPath: config.yaml + readOnly: true + envFrom: + - secretRef: + name: gitmeout-secrets + resources: + requests: + memory: "64Mi" + cpu: "50m" + limits: + memory: "128Mi" + cpu: "100m" + volumes: + - name: config + secret: + secretName: gitmeout-config diff --git a/deploy/k8s/job.yaml b/deploy/k8s/job.yaml new file mode 100644 index 0000000..bebbe50 --- /dev/null +++ b/deploy/k8s/job.yaml @@ -0,0 +1,66 @@ +apiVersion: batch/v1 +kind: Job +metadata: + name: gitmeout + labels: + app: gitmeout +spec: + template: + metadata: + labels: + app: gitmeout + spec: + restartPolicy: Never + containers: + - name: gitmeout + image: ghcr.io/guilycst/gitmeout:latest + imagePullPolicy: Always + volumeMounts: + - name: config + mountPath: /app/config.yaml + subPath: config.yaml + readOnly: true + envFrom: + - secretRef: + name: gitmeout-secrets + resources: + requests: + memory: "64Mi" + cpu: "50m" + limits: + memory: "128Mi" + cpu: "100m" + volumes: + - name: config + secret: + secretName: gitmeout-config +--- +apiVersion: v1 +kind: Secret +metadata: + name: gitmeout-secrets +type: Opaque +stringData: + GITHUB_TOKEN: "your-github-token-here" + CODEBERG_TOKEN: "your-codeberg-token-here" +--- +apiVersion: v1 +kind: Secret +metadata: + name: gitmeout-config +type: Opaque +stringData: + config.yaml: | + source: + type: github + token: ${GITHUB_TOKEN} + filters: + personal: true + orgs: [] + repos: [] + + targets: + - name: codeberg + type: forgejo + url: https://codeberg.org + token: ${CODEBERG_TOKEN} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7ab6f0a --- /dev/null +++ b/go.mod @@ -0,0 +1,40 @@ +module github.com/guilycst/gitmeout + +go 1.26 + +require ( + codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v3 v3.0.0 + github.com/google/go-github/v69 v69.2.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/42wim/httpsig v1.2.3 // indirect + github.com/davidmz/go-pageant v1.0.2 // indirect + github.com/go-fed/httpsig v1.1.0 // indirect + github.com/go-openapi/errors v0.22.6 // indirect + github.com/go-openapi/strfmt v0.25.0 // indirect + github.com/go-openapi/swag v0.25.4 // indirect + github.com/go-openapi/swag/cmdutils v0.25.4 // indirect + github.com/go-openapi/swag/conv v0.25.4 // indirect + github.com/go-openapi/swag/fileutils v0.25.4 // indirect + github.com/go-openapi/swag/jsonname v0.25.4 // indirect + github.com/go-openapi/swag/jsonutils v0.25.4 // indirect + github.com/go-openapi/swag/loading v0.25.4 // indirect + github.com/go-openapi/swag/mangling v0.25.4 // indirect + github.com/go-openapi/swag/netutils v0.25.4 // indirect + github.com/go-openapi/swag/stringutils v0.25.4 // indirect + github.com/go-openapi/swag/typeutils v0.25.4 // indirect + github.com/go-openapi/swag/yamlutils v0.25.4 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/google/go-querystring v1.1.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/go-version v1.7.0 // indirect + github.com/oklog/ulid v1.3.1 // indirect + go.mongodb.org/mongo-driver v1.17.6 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.45.0 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.31.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ac962c3 --- /dev/null +++ b/go.sum @@ -0,0 +1,94 @@ +codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v3 v3.0.0 h1:s2fK+FBwvcYsmKDjNhmoe7B8q9zsgs0UrSlYe9r4XjM= +codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v3 v3.0.0/go.mod h1:Is2jTpS1dizeXm4skQv/ES3QVqnzcNhn2GzZXpiw9f8= +github.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs= +github.com/42wim/httpsig v1.2.3/go.mod h1:nZq9OlYKDrUBhptd77IHx4/sZZD+IxTBADvAPI9G/EM= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0= +github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE= +github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI= +github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM= +github.com/go-openapi/errors v0.22.6 h1:eDxcf89O8odEnohIXwEjY1IB4ph5vmbUsBMsFNwXWPo= +github.com/go-openapi/errors v0.22.6/go.mod h1:z9S8ASTUqx7+CP1Q8dD8ewGH/1JWFFLX/2PmAYNQLgk= +github.com/go-openapi/strfmt v0.25.0 h1:7R0RX7mbKLa9EYCTHRcCuIPcaqlyQiWNPTXwClK0saQ= +github.com/go-openapi/strfmt v0.25.0/go.mod h1:nNXct7OzbwrMY9+5tLX4I21pzcmE6ccMGXl3jFdPfn8= +github.com/go-openapi/swag v0.25.4 h1:OyUPUFYDPDBMkqyxOTkqDYFnrhuhi9NR6QVUvIochMU= +github.com/go-openapi/swag v0.25.4/go.mod h1:zNfJ9WZABGHCFg2RnY0S4IOkAcVTzJ6z2Bi+Q4i6qFQ= +github.com/go-openapi/swag/cmdutils v0.25.4 h1:8rYhB5n6WawR192/BfUu2iVlxqVR9aRgGJP6WaBoW+4= +github.com/go-openapi/swag/cmdutils v0.25.4/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0= +github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4= +github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU= +github.com/go-openapi/swag/fileutils v0.25.4 h1:2oI0XNW5y6UWZTC7vAxC8hmsK/tOkWXHJQH4lKjqw+Y= +github.com/go-openapi/swag/fileutils v0.25.4/go.mod h1:cdOT/PKbwcysVQ9Tpr0q20lQKH7MGhOEb6EwmHOirUk= +github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI= +github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag= +github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA= +github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4 h1:IACsSvBhiNJwlDix7wq39SS2Fh7lUOCJRmx/4SN4sVo= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4/go.mod h1:Mt0Ost9l3cUzVv4OEZG+WSeoHwjWLnarzMePNDAOBiM= +github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s= +github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE= +github.com/go-openapi/swag/mangling v0.25.4 h1:2b9kBJk9JvPgxr36V23FxJLdwBrpijI26Bx5JH4Hp48= +github.com/go-openapi/swag/mangling v0.25.4/go.mod h1:6dxwu6QyORHpIIApsdZgb6wBk/DPU15MdyYj/ikn0Hg= +github.com/go-openapi/swag/netutils v0.25.4 h1:Gqe6K71bGRb3ZQLusdI8p/y1KLgV4M/k+/HzVSqT8H0= +github.com/go-openapi/swag/netutils v0.25.4/go.mod h1:m2W8dtdaoX7oj9rEttLyTeEFFEBvnAx9qHd5nJEBzYg= +github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8= +github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0= +github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw= +github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE= +github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw= +github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc= +github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4= +github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg= +github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls= +github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-github/v69 v69.2.0 h1:wR+Wi/fN2zdUx9YxSmYE0ktiX9IAR/BeePzeaUUbEHE= +github.com/google/go-github/v69 v69.2.0/go.mod h1:xne4jymxLR6Uj9b7J7PyTpkMYstEMMwGZa0Aehh1azM= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= +github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= +github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.mongodb.org/mongo-driver v1.17.6 h1:87JUG1wZfWsr6rIz3ZmpH90rL5tea7O3IHuSwHUpsss= +go.mongodb.org/mongo-driver v1.17.6/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= +golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= +golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..0c4bd56 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,131 @@ +package config + +import ( + "fmt" + "os" + "regexp" + "strings" + + "gopkg.in/yaml.v3" +) + +type Config struct { + Source Source `yaml:"source"` + Targets []Target `yaml:"targets"` +} + +type Source struct { + Type string `yaml:"type"` + Token string `yaml:"token"` + Filters Filters `yaml:"filters"` +} + +type Filters struct { + Personal bool `yaml:"personal"` + Orgs []string `yaml:"orgs"` + Repos []string `yaml:"repos"` +} + +type Target struct { + Name string `yaml:"name"` + Type string `yaml:"type"` + URL string `yaml:"url"` + Token string `yaml:"token"` + MirrorType string `yaml:"mirror_type"` // "push" or "pull", defaults to "push" +} + +func Load(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read config file: %w", err) + } + + expanded := expandEnvVars(string(data)) + + var cfg Config + if err := yaml.Unmarshal([]byte(expanded), &cfg); err != nil { + return nil, fmt.Errorf("failed to parse config: %w", err) + } + + if err := cfg.validate(); err != nil { + return nil, err + } + + return &cfg, nil +} + +func expandEnvVars(s string) string { + re := regexp.MustCompile(`\$\{([^}]+)\}`) + return re.ReplaceAllStringFunc(s, func(match string) string { + varName := match[2 : len(match)-1] + return os.Getenv(varName) + }) +} + +func (c *Config) validate() error { + if c.Source.Type != "github" { + return fmt.Errorf("source type must be 'github', got %q", c.Source.Type) + } + + if c.Source.Token == "" { + return fmt.Errorf("source token is required") + } + + if len(c.Targets) == 0 { + return fmt.Errorf("at least one target is required") + } + + for i, t := range c.Targets { + if t.Name == "" { + return fmt.Errorf("target[%d].name is required", i) + } + if t.Type != "forgejo" { + return fmt.Errorf("target[%d].type must be 'forgejo', got %q", i, t.Type) + } + if t.URL == "" { + return fmt.Errorf("target[%d].url is required", i) + } + if t.Token == "" { + return fmt.Errorf("target[%d].token is required", i) + } + if t.MirrorType != "" && t.MirrorType != "push" && t.MirrorType != "pull" { + return fmt.Errorf("target[%d].mirror_type must be 'push' or 'pull', got %q", i, t.MirrorType) + } + if t.MirrorType == "" { + c.Targets[i].MirrorType = "push" + } + } + + return nil +} + +func Parse(data string) (*Config, error) { + expanded := expandEnvVars(data) + + var cfg Config + if err := yaml.Unmarshal([]byte(expanded), &cfg); err != nil { + return nil, fmt.Errorf("failed to parse config: %w", err) + } + + if err := cfg.validate(); err != nil { + return nil, err + } + + return &cfg, nil +} + +func (f *Filters) HasRepoFilter() bool { + return len(f.Repos) > 0 +} + +func (f *Filters) HasOrgFilter() bool { + return len(f.Orgs) > 0 +} + +func ParseRepoSpec(spec string) (owner, repo string, isWildcard bool) { + parts := strings.SplitN(spec, "/", 2) + if len(parts) != 2 { + return parts[0], "", false + } + return parts[0], parts[1], parts[1] == "*" +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..d5580cb --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,223 @@ +package config + +import ( + "os" + "strings" + "testing" +) + +func TestExpandEnvVars(t *testing.T) { + tests := []struct { + name string + input string + envVars map[string]string + expected string + }{ + { + name: "no env vars", + input: "hello world", + envVars: nil, + expected: "hello world", + }, + { + name: "single env var", + input: "token: ${TOKEN}", + envVars: map[string]string{"TOKEN": "secret123"}, + expected: "token: secret123", + }, + { + name: "multiple env vars", + input: "${VAR1} and ${VAR2}", + envVars: map[string]string{"VAR1": "first", "VAR2": "second"}, + expected: "first and second", + }, + { + name: "missing env var", + input: "token: ${MISSING}", + envVars: nil, + expected: "token: ", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + for k, v := range tt.envVars { + if err := os.Setenv(k, v); err != nil { + t.Fatalf("failed to set env: %v", err) + } + t.Cleanup(func() { + _ = os.Unsetenv(k) + }) + } + + got := expandEnvVars(tt.input) + if got != tt.expected { + t.Errorf("expandEnvVars() = %q, want %q", got, tt.expected) + } + }) + } +} + +func TestParse(t *testing.T) { + tests := []struct { + name string + input string + wantErr bool + errMsg string + }{ + { + name: "valid config", + input: ` +source: + type: github + token: test-token + filters: + personal: true + orgs: [] + repos: [] +targets: + - name: codeberg + type: forgejo + url: https://codeberg.org + token: test-token +`, + wantErr: false, + }, + { + name: "missing source token", + input: ` +source: + type: github + token: "" +targets: + - name: codeberg + type: forgejo + url: https://codeberg.org + token: test-token +`, + wantErr: true, + errMsg: "source token is required", + }, + { + name: "invalid source type", + input: ` +source: + type: gitlab + token: test-token +targets: + - name: codeberg + type: forgejo + url: https://codeberg.org + token: test-token +`, + wantErr: true, + errMsg: "source type must be 'github'", + }, + { + name: "no targets", + input: ` +source: + type: github + token: test-token +targets: [] +`, + wantErr: true, + errMsg: "at least one target is required", + }, + { + name: "missing target token", + input: ` +source: + type: github + token: test-token +targets: + - name: codeberg + type: forgejo + url: https://codeberg.org + token: "" +`, + wantErr: true, + errMsg: "token is required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := Parse(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("Parse() error = %v, wantErr %v", err, tt.wantErr) + return + } + if tt.wantErr && !strings.Contains(err.Error(), tt.errMsg) { + t.Errorf("Parse() error = %v, want error containing %q", err, tt.errMsg) + } + }) + } +} + +func TestParseRepoSpec(t *testing.T) { + tests := []struct { + name string + spec string + wantOwner string + wantRepo string + wantWildcard bool + }{ + { + name: "specific repo", + spec: "owner/repo", + wantOwner: "owner", + wantRepo: "repo", + wantWildcard: false, + }, + { + name: "wildcard", + spec: "owner/*", + wantOwner: "owner", + wantRepo: "*", + wantWildcard: true, + }, + { + name: "no slash", + spec: "owner", + wantOwner: "owner", + wantRepo: "", + wantWildcard: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotOwner, gotRepo, gotWildcard := ParseRepoSpec(tt.spec) + if gotOwner != tt.wantOwner { + t.Errorf("ParseRepoSpec() owner = %q, want %q", gotOwner, tt.wantOwner) + } + if gotRepo != tt.wantRepo { + t.Errorf("ParseRepoSpec() repo = %q, want %q", gotRepo, tt.wantRepo) + } + if gotWildcard != tt.wantWildcard { + t.Errorf("ParseRepoSpec() wildcard = %v, want %v", gotWildcard, tt.wantWildcard) + } + }) + } +} + +func TestFilters(t *testing.T) { + t.Run("HasRepoFilter", func(t *testing.T) { + if (&Filters{Repos: []string{"a/b"}}).HasRepoFilter() != true { + t.Error("expected HasRepoFilter to be true") + } + if (&Filters{}).HasRepoFilter() != false { + t.Error("expected HasRepoFilter to be false") + } + }) + + t.Run("HasOrgFilter", func(t *testing.T) { + if (&Filters{Orgs: []string{"org1"}}).HasOrgFilter() != true { + t.Error("expected HasOrgFilter to be true") + } + if (&Filters{}).HasOrgFilter() != false { + t.Error("expected HasOrgFilter to be false") + } + }) +} diff --git a/internal/git/client.go b/internal/git/client.go new file mode 100644 index 0000000..18a40c5 --- /dev/null +++ b/internal/git/client.go @@ -0,0 +1,109 @@ +package git + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +type Client struct { + tempDir string +} + +func NewClient() (*Client, error) { + tempDir, err := os.MkdirTemp("", "gitmeout-*") + if err != nil { + return nil, fmt.Errorf("failed to create temp dir: %w", err) + } + return &Client{tempDir: tempDir}, nil +} + +func (c *Client) Close() error { + if c.tempDir != "" { + return os.RemoveAll(c.tempDir) + } + return nil +} + +func (c *Client) Clone(ctx context.Context, url, token, dest string) error { + authURL := injectToken(url, token) + + cmd := exec.CommandContext(ctx, "git", "clone", "--bare", authURL, dest) + cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0") + + output, err := cmd.CombinedOutput() + if err != nil { + if ctx.Err() != nil { + return fmt.Errorf("clone cancelled: %w", ctx.Err()) + } + return fmt.Errorf("failed to clone %s: %w\n%s", url, err, string(output)) + } + return nil +} + +func (c *Client) AddRemote(ctx context.Context, repoPath, name, url, token string) error { + authURL := injectToken(url, token) + + cmd := exec.CommandContext(ctx, "git", "remote", "add", name, authURL) + cmd.Dir = repoPath + cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0") + + output, err := cmd.CombinedOutput() + if err != nil { + if ctx.Err() != nil { + return fmt.Errorf("add remote cancelled: %w", ctx.Err()) + } + return fmt.Errorf("failed to add remote: %w\n%s", err, string(output)) + } + return nil +} + +func (c *Client) PushMirror(ctx context.Context, repoPath, remote string) error { + cmd := exec.CommandContext(ctx, "git", "push", "--mirror", remote) + cmd.Dir = repoPath + cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0") + + output, err := cmd.CombinedOutput() + if err != nil { + if ctx.Err() != nil { + return fmt.Errorf("push cancelled: %w", ctx.Err()) + } + return fmt.Errorf("failed to push mirror: %w\n%s", err, string(output)) + } + return nil +} + +func (c *Client) CloneAndPush(ctx context.Context, srcURL, srcToken, destURL, destToken, repoName string) error { + repoPath := filepath.Join(c.tempDir, repoName) + + if err := c.Clone(ctx, srcURL, srcToken, repoPath); err != nil { + return err + } + + if err := c.AddRemote(ctx, repoPath, "target", destURL, destToken); err != nil { + return err + } + + if err := c.PushMirror(ctx, repoPath, "target"); err != nil { + return err + } + + return nil +} + +func injectToken(url, token string) string { + if token == "" { + return url + } + + if strings.HasPrefix(url, "https://") { + return strings.Replace(url, "https://", "https://"+token+"@", 1) + } + if strings.HasPrefix(url, "http://") { + return strings.Replace(url, "http://", "http://"+token+"@", 1) + } + return url +} diff --git a/internal/mirror/service.go b/internal/mirror/service.go new file mode 100644 index 0000000..c1cb696 --- /dev/null +++ b/internal/mirror/service.go @@ -0,0 +1,219 @@ +package mirror + +import ( + "context" + "fmt" + "log/slog" + + "github.com/guilycst/gitmeout/internal/config" + "github.com/guilycst/gitmeout/internal/git" + "github.com/guilycst/gitmeout/internal/source/github" +) + +type Repository struct { + Owner string + Name string + FullName string + CloneURL string + DefaultBranch string + Private bool + AuthToken string +} + +type Source interface { + ListUserRepos(ctx context.Context) ([]github.Repository, error) + ListOrgRepos(ctx context.Context, org string) ([]github.Repository, error) + ListOwnerRepos(ctx context.Context, owner string) ([]github.Repository, error) + GetRepo(ctx context.Context, owner, name string) (*github.Repository, error) + GetAuthenticatedUser(ctx context.Context) (string, error) +} + +type Target interface { + Name() string + MirrorType() string // "push" or "pull" + CreateRepo(ctx context.Context, repo Repository) error + MigrateRepo(ctx context.Context, repo Repository) error + Exists(ctx context.Context, repo Repository) (bool, error) + GetCloneURL(ctx context.Context, repo Repository) (string, error) + GetAuthToken() string +} + +type Service struct { + source Source + targets []Target + sourceAuth string +} + +func NewService(source Source, targets []Target, sourceAuth string) *Service { + return &Service{ + source: source, + targets: targets, + sourceAuth: sourceAuth, + } +} + +func (s *Service) Run(ctx context.Context, filters config.Filters) error { + repos, err := s.resolveRepositories(ctx, filters) + if err != nil { + return fmt.Errorf("failed to resolve repositories: %w", err) + } + + slog.Info("resolved repositories", "count", len(repos)) + + for _, repo := range repos { + select { + case <-ctx.Done(): + slog.Info("mirroring interrupted", "reason", ctx.Err()) + return ctx.Err() + default: + } + + if err := s.mirrorToTargets(ctx, repo); err != nil { + slog.Error("failed to mirror repository", "repo", repo.FullName, "error", err) + continue + } + } + + return nil +} + +func (s *Service) resolveRepositories(ctx context.Context, filters config.Filters) ([]Repository, error) { + var repos []Repository + seen := make(map[string]bool) + + addRepo := func(r github.Repository) { + if !seen[r.FullName] { + seen[r.FullName] = true + repos = append(repos, Repository{ + Owner: r.Owner, + Name: r.Name, + FullName: r.FullName, + CloneURL: r.CloneURL, + DefaultBranch: r.DefaultBranch, + Private: r.Private, + AuthToken: s.sourceAuth, + }) + } + } + + if filters.HasRepoFilter() { + for _, spec := range filters.Repos { + owner, repo, isWildcard := config.ParseRepoSpec(spec) + + if isWildcard { + ownerRepos, err := s.source.ListOwnerRepos(ctx, owner) + if err != nil { + return nil, fmt.Errorf("failed to list repos for %s: %w", owner, err) + } + for _, r := range ownerRepos { + addRepo(r) + } + } else { + r, err := s.source.GetRepo(ctx, owner, repo) + if err != nil { + return nil, fmt.Errorf("failed to get repo %s: %w", spec, err) + } + addRepo(*r) + } + } + return repos, nil + } + + if filters.Personal { + userRepos, err := s.source.ListUserRepos(ctx) + if err != nil { + return nil, fmt.Errorf("failed to list personal repos: %w", err) + } + for _, r := range userRepos { + addRepo(r) + } + } + + for _, org := range filters.Orgs { + orgRepos, err := s.source.ListOrgRepos(ctx, org) + if err != nil { + return nil, fmt.Errorf("failed to list repos for org %s: %w", org, err) + } + for _, r := range orgRepos { + addRepo(r) + } + } + + return repos, nil +} + +func (s *Service) mirrorToTargets(ctx context.Context, repo Repository) error { + for _, target := range s.targets { + exists, err := target.Exists(ctx, repo) + if err != nil { + return fmt.Errorf("failed to check if repo exists on %s: %w", target.Name(), err) + } + + slog.Info("syncing mirror", + "repo", repo.FullName, + "target", target.Name(), + "mirror_type", target.MirrorType(), + "exists", exists) + + if target.MirrorType() == "pull" { + if err := s.pullMirror(ctx, target, repo, exists); err != nil { + return fmt.Errorf("failed to sync mirror on %s: %w", target.Name(), err) + } + } else { + if err := s.pushMirror(ctx, target, repo, exists); err != nil { + return fmt.Errorf("failed to sync mirror on %s: %w", target.Name(), err) + } + } + + slog.Info("mirror synced successfully", + "repo", repo.FullName, + "target", target.Name()) + } + + return nil +} + +func (s *Service) pullMirror(ctx context.Context, target Target, repo Repository, exists bool) error { + if exists { + slog.Info("pull mirror already exists, skipping", + "repo", repo.FullName, + "target", target.Name()) + return nil + } + + if err := target.MigrateRepo(ctx, repo); err != nil { + return fmt.Errorf("failed to migrate repo: %w", err) + } + + return nil +} + +func (s *Service) pushMirror(ctx context.Context, target Target, repo Repository, exists bool) error { + if !exists { + if err := target.CreateRepo(ctx, repo); err != nil { + return fmt.Errorf("failed to create repo: %w", err) + } + } + + destURL, err := target.GetCloneURL(ctx, repo) + if err != nil { + return fmt.Errorf("failed to get clone URL: %w", err) + } + + gitClient, err := git.NewClient() + if err != nil { + return fmt.Errorf("failed to create git client: %w", err) + } + defer func() { + if err := gitClient.Close(); err != nil { + slog.Warn("failed to close git client", "error", err) + } + }() + + destToken := target.GetAuthToken() + if err := gitClient.CloneAndPush(ctx, repo.CloneURL, repo.AuthToken, destURL, destToken, repo.Name); err != nil { + return fmt.Errorf("failed to push mirror: %w", err) + } + + return nil +} diff --git a/internal/mirror/service_test.go b/internal/mirror/service_test.go new file mode 100644 index 0000000..443a28b --- /dev/null +++ b/internal/mirror/service_test.go @@ -0,0 +1,213 @@ +package mirror + +import ( + "context" + "errors" + "testing" + + "github.com/guilycst/gitmeout/internal/config" + "github.com/guilycst/gitmeout/internal/source/github" +) + +type mockSource struct { + userRepos []github.Repository + orgRepos map[string][]github.Repository + ownerRepos map[string][]github.Repository + repo map[string]*github.Repository + user string + err error +} + +func (m *mockSource) ListUserRepos(ctx context.Context) ([]github.Repository, error) { + return m.userRepos, m.err +} + +func (m *mockSource) ListOrgRepos(ctx context.Context, org string) ([]github.Repository, error) { + return m.orgRepos[org], m.err +} + +func (m *mockSource) ListOwnerRepos(ctx context.Context, owner string) ([]github.Repository, error) { + return m.ownerRepos[owner], m.err +} + +func (m *mockSource) GetRepo(ctx context.Context, owner, name string) (*github.Repository, error) { + key := owner + "/" + name + return m.repo[key], m.err +} + +func (m *mockSource) GetAuthenticatedUser(ctx context.Context) (string, error) { + return m.user, m.err +} + +type mockTarget struct { + name string + existing map[string]bool + created []string + cloneURL string + mirrorType string + err error +} + +func (m *mockTarget) Name() string { + return m.name +} + +func (m *mockTarget) MirrorType() string { + if m.mirrorType == "" { + return "push" + } + return m.mirrorType +} + +func (m *mockTarget) Exists(ctx context.Context, repo Repository) (bool, error) { + return m.existing[repo.FullName], m.err +} + +func (m *mockTarget) CreateRepo(ctx context.Context, repo Repository) error { + m.created = append(m.created, repo.FullName) + return m.err +} + +func (m *mockTarget) MigrateRepo(ctx context.Context, repo Repository) error { + m.created = append(m.created, repo.FullName) + return m.err +} + +func (m *mockTarget) GetCloneURL(ctx context.Context, repo Repository) (string, error) { + if m.cloneURL != "" { + return m.cloneURL, nil + } + return "https://example.com/" + repo.Name, nil +} + +func (m *mockTarget) GetAuthToken() string { + return "test-token" +} + +func (m *mockTarget) CreateMirror(ctx context.Context, repo Repository) error { + m.created = append(m.created, repo.FullName) + return m.err +} + +func TestService_Run(t *testing.T) { + tests := []struct { + name string + source *mockSource + target *mockTarget + filters config.Filters + wantCreated []string + wantErr bool + }{ + { + name: "mirror new repo", + source: &mockSource{ + userRepos: []github.Repository{ + {Owner: "user", Name: "repo1", FullName: "user/repo1", CloneURL: "https://github.com/user/repo1.git"}, + }, + }, + target: &mockTarget{name: "forgejo", existing: map[string]bool{}}, + filters: config.Filters{Personal: true}, + wantCreated: []string{"user/repo1"}, + wantErr: false, + }, + { + name: "skip existing mirror", + source: &mockSource{ + userRepos: []github.Repository{ + {Owner: "user", Name: "repo1", FullName: "user/repo1", CloneURL: "https://github.com/user/repo1.git"}, + }, + }, + target: &mockTarget{name: "forgejo", existing: map[string]bool{"user/repo1": true}}, + filters: config.Filters{Personal: true}, + wantCreated: []string{}, + wantErr: false, + }, + { + name: "explicit repo list", + source: &mockSource{ + repo: map[string]*github.Repository{ + "owner/repo1": {Owner: "owner", Name: "repo1", FullName: "owner/repo1", CloneURL: "https://github.com/owner/repo1.git"}, + }, + }, + target: &mockTarget{name: "forgejo", existing: map[string]bool{}}, + filters: config.Filters{Repos: []string{"owner/repo1"}}, + wantCreated: []string{"owner/repo1"}, + wantErr: false, + }, + { + name: "wildcard repo spec", + source: &mockSource{ + ownerRepos: map[string][]github.Repository{ + "owner": { + {Owner: "owner", Name: "repo1", FullName: "owner/repo1", CloneURL: "https://github.com/owner/repo1.git"}, + {Owner: "owner", Name: "repo2", FullName: "owner/repo2", CloneURL: "https://github.com/owner/repo2.git"}, + }, + }, + }, + target: &mockTarget{name: "forgejo", existing: map[string]bool{}}, + filters: config.Filters{Repos: []string{"owner/*"}}, + wantCreated: []string{"owner/repo1", "owner/repo2"}, + wantErr: false, + }, + { + name: "org repos", + source: &mockSource{ + orgRepos: map[string][]github.Repository{ + "my-org": { + {Owner: "my-org", Name: "org-repo", FullName: "my-org/org-repo", CloneURL: "https://github.com/my-org/org-repo.git"}, + }, + }, + }, + target: &mockTarget{name: "forgejo", existing: map[string]bool{}}, + filters: config.Filters{Personal: false, Orgs: []string{"my-org"}}, + wantCreated: []string{"my-org/org-repo"}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + svc := NewService(tt.source, []Target{tt.target}, "test-token") + err := svc.Run(context.Background(), tt.filters) + + if (err != nil) != tt.wantErr { + t.Errorf("Run() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if len(tt.target.created) != len(tt.wantCreated) { + t.Errorf("Run() created %d repos, want %d", len(tt.target.created), len(tt.wantCreated)) + return + } + + for i, want := range tt.wantCreated { + if tt.target.created[i] != want { + t.Errorf("Run() created[%d] = %q, want %q", i, tt.target.created[i], want) + } + } + }) + } +} + +func TestService_Run_TargetError(t *testing.T) { + source := &mockSource{ + userRepos: []github.Repository{ + {Owner: "user", Name: "repo1", FullName: "user/repo1", CloneURL: "https://github.com/user/repo1.git"}, + }, + } + target := &mockTarget{ + name: "forgejo", + existing: map[string]bool{}, + err: errors.New("connection failed"), + } + + svc := NewService(source, []Target{target}, "test-token") + err := svc.Run(context.Background(), config.Filters{Personal: true}) + + if err != nil { + t.Errorf("Run() expected no error with continue-on-error behavior, got: %v", err) + } + if len(target.created) != 0 { + t.Errorf("expected no repos created, got %d", len(target.created)) + } +} diff --git a/internal/source/github/client.go b/internal/source/github/client.go new file mode 100644 index 0000000..3aae65f --- /dev/null +++ b/internal/source/github/client.go @@ -0,0 +1,136 @@ +package github + +import ( + "context" + "fmt" + + gh "github.com/google/go-github/v69/github" +) + +type Client struct { + client *gh.Client +} + +func NewClient(token string) (*Client, error) { + if token == "" { + return nil, fmt.Errorf("token is required") + } + + client := gh.NewClient(nil).WithAuthToken(token) + return &Client{client: client}, nil +} + +type Repository struct { + Owner string + Name string + FullName string + CloneURL string + SSHURL string + Private bool + DefaultBranch string +} + +func (c *Client) ListUserRepos(ctx context.Context) ([]Repository, error) { + opt := &gh.RepositoryListByAuthenticatedUserOptions{ + ListOptions: gh.ListOptions{PerPage: 100}, + } + + var repos []Repository + for { + ghRepos, resp, err := c.client.Repositories.ListByAuthenticatedUser(ctx, opt) + if err != nil { + return nil, fmt.Errorf("failed to list user repos: %w", err) + } + + for _, r := range ghRepos { + repos = append(repos, toRepository(r)) + } + + if resp.NextPage == 0 { + break + } + opt.Page = resp.NextPage + } + + return repos, nil +} + +func (c *Client) ListOrgRepos(ctx context.Context, org string) ([]Repository, error) { + opt := &gh.RepositoryListByOrgOptions{ + ListOptions: gh.ListOptions{PerPage: 100}, + } + + var repos []Repository + for { + ghRepos, resp, err := c.client.Repositories.ListByOrg(ctx, org, opt) + if err != nil { + return nil, fmt.Errorf("failed to list org repos for %s: %w", org, err) + } + + for _, r := range ghRepos { + repos = append(repos, toRepository(r)) + } + + if resp.NextPage == 0 { + break + } + opt.Page = resp.NextPage + } + + return repos, nil +} + +func (c *Client) ListOwnerRepos(ctx context.Context, owner string) ([]Repository, error) { + opt := &gh.RepositoryListByUserOptions{ + ListOptions: gh.ListOptions{PerPage: 100}, + } + + var repos []Repository + for { + ghRepos, resp, err := c.client.Repositories.ListByUser(ctx, owner, opt) + if err != nil { + return nil, fmt.Errorf("failed to list repos for owner %s: %w", owner, err) + } + + for _, r := range ghRepos { + repos = append(repos, toRepository(r)) + } + + if resp.NextPage == 0 { + break + } + opt.Page = resp.NextPage + } + + return repos, nil +} + +func (c *Client) GetRepo(ctx context.Context, owner, name string) (*Repository, error) { + repo, _, err := c.client.Repositories.Get(ctx, owner, name) + if err != nil { + return nil, fmt.Errorf("failed to get repo %s/%s: %w", owner, name, err) + } + + r := toRepository(repo) + return &r, nil +} + +func (c *Client) GetAuthenticatedUser(ctx context.Context) (string, error) { + user, _, err := c.client.Users.Get(ctx, "") + if err != nil { + return "", fmt.Errorf("failed to get authenticated user: %w", err) + } + return user.GetLogin(), nil +} + +func toRepository(r *gh.Repository) Repository { + return Repository{ + Owner: r.GetOwner().GetLogin(), + Name: r.GetName(), + FullName: r.GetFullName(), + CloneURL: r.GetCloneURL(), + SSHURL: r.GetSSHURL(), + Private: r.GetPrivate(), + DefaultBranch: r.GetDefaultBranch(), + } +} diff --git a/internal/target/forgejo/client.go b/internal/target/forgejo/client.go new file mode 100644 index 0000000..1238f93 --- /dev/null +++ b/internal/target/forgejo/client.go @@ -0,0 +1,136 @@ +package forgejo + +import ( + "context" + "fmt" + "sync" + + forgejo "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v3" + "github.com/guilycst/gitmeout/internal/mirror" +) + +type Client struct { + client *forgejo.Client + url string + token string + user string + once sync.Once + userErr error +} + +func NewClient(url, token string) (*Client, error) { + if url == "" { + return nil, fmt.Errorf("url is required") + } + if token == "" { + return nil, fmt.Errorf("token is required") + } + + client, err := forgejo.NewClient(url, forgejo.SetToken(token)) + if err != nil { + return nil, fmt.Errorf("failed to create forgejo client: %w", err) + } + + return &Client{client: client, url: url, token: token}, nil +} + +func (c *Client) getUsername(ctx context.Context) (string, error) { + c.once.Do(func() { + user, _, err := c.client.GetMyUserInfo() + if err != nil { + c.userErr = fmt.Errorf("failed to get authenticated user: %w", err) + return + } + c.user = user.UserName + }) + return c.user, c.userErr +} + +func (c *Client) URL() string { + return c.url +} + +func (c *Client) GetAuthToken() string { + return c.token +} + +func (c *Client) CreateRepo(ctx context.Context, repo mirror.Repository) error { + _, err := c.getUsername(ctx) + if err != nil { + return fmt.Errorf("failed to get username: %w", err) + } + + opts := forgejo.CreateRepoOption{ + Name: repo.Name, + Private: repo.Private, + AutoInit: false, + DefaultBranch: repo.DefaultBranch, + } + + _, _, err = c.client.CreateRepo(opts) + if err != nil { + return fmt.Errorf("failed to create repo %s: %w", repo.Name, err) + } + + return nil +} + +func (c *Client) Exists(ctx context.Context, repo mirror.Repository) (bool, error) { + username, err := c.getUsername(ctx) + if err != nil { + return false, fmt.Errorf("failed to get username: %w", err) + } + + _, resp, err := c.client.GetRepo(username, repo.Name) + if err != nil { + if resp != nil && resp.StatusCode == 404 { + return false, nil + } + return false, fmt.Errorf("failed to check if repo exists: %w", err) + } + return true, nil +} + +func (c *Client) GetCloneURL(ctx context.Context, repo mirror.Repository) (string, error) { + username, err := c.getUsername(ctx) + if err != nil { + return "", fmt.Errorf("failed to get username: %w", err) + } + + r, _, err := c.client.GetRepo(username, repo.Name) + if err != nil { + return "", fmt.Errorf("failed to get repo: %w", err) + } + + return r.CloneURL, nil +} + +func (c *Client) MigrateRepo(ctx context.Context, repo mirror.Repository) error { + username, err := c.getUsername(ctx) + if err != nil { + return fmt.Errorf("failed to get username: %w", err) + } + + opts := forgejo.MigrateRepoOption{ + RepoName: repo.Name, + RepoOwner: username, + CloneAddr: repo.CloneURL, + AuthToken: repo.AuthToken, + Service: forgejo.GitServiceGithub, + Mirror: true, + Private: repo.Private, + Wiki: true, + Issues: true, + Milestones: true, + Labels: true, + PullRequests: true, + Releases: true, + } + + _, _, err = c.client.MigrateRepo(opts) + if err != nil { + return fmt.Errorf("failed to migrate repo %s: %w", repo.Name, err) + } + + return nil +}