diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 1f98849dd..600f249ba 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -24,4 +24,16 @@ This is a C# based repository that produces several CLIs that are used by custom 4. Write unit tests for new functionality. 5. When making changes that would impact our users (e.g. new features or bug fixes), add a bullet point to `RELEASENOTES.md` with a user friendly brief description of the change 6. Never silently swallow exceptions. -7. If an exception is expected/understood and we can give a helpful user-friendly message, then throw an OctoshiftCliException with a user-friendly message. Otherwise let the exception bubble up and the top-level exception handler will log and handle it appropriately. \ No newline at end of file +7. If an exception is expected/understood and we can give a helpful user-friendly message, then throw an OctoshiftCliException with a user-friendly message. Otherwise let the exception bubble up and the top-level exception handler will log and handle it appropriately. + +## Go Port (In Progress) + +A Go port of these CLIs is underway. The Go code lives alongside the C# code: + +- `cmd/gei/`, `cmd/ado2gh/`, `cmd/bbs2gh/`: Go CLI entry points (skeleton only at this stage) +- `pkg/`: Shared Go packages (logger, env) +- `internal/`: Internal Go packages (command utilities) + +**Current state:** The Go port contains only the base framework — CLI skeleton, logger, environment variable provider, and command structure. No commands have behavioral parity with C# yet. + +**When making C# changes:** No Go sync is required at this stage. Just be aware the Go directories exist and avoid naming conflicts. diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index d0eebc6fc..89d09efb2 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -24,7 +24,7 @@ jobs: fail-fast: false matrix: runner-os: [windows-latest, ubuntu-latest, macos-latest] - language: [ csharp, actions ] + language: [csharp, actions] runs-on: ${{ matrix.runner-os }} diff --git a/.github/workflows/go-ci.yml b/.github/workflows/go-ci.yml new file mode 100644 index 000000000..ce2d1345a --- /dev/null +++ b/.github/workflows/go-ci.yml @@ -0,0 +1,101 @@ +name: Go CI + +on: + push: + branches: [main] + pull_request: + branches: + - main + - o1/golang-port/* + workflow_dispatch: + +permissions: + contents: read + +jobs: + go-build-and-test: + name: Go Build and Test + strategy: + fail-fast: false + matrix: + runner-os: [windows-latest, ubuntu-latest, macos-latest] + + runs-on: ${{ matrix.runner-os }} + + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Setup Go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + + - name: Setup Just + uses: extractions/setup-just@e33e0265a09d6d736e2ee1e0eb685ef1de4669ff # v3 + + - name: Check Go formatting + if: matrix.runner-os == 'ubuntu-latest' + run: | + if [ -n "$(gofmt -l .)" ]; then + echo "Go files need formatting:" + gofmt -l . + exit 1 + fi + + - name: Go Vet + run: go vet ./... + + - name: Build Go binaries + run: just go-build + + - name: Run Go tests + run: go test -v -race ./... + if: matrix.runner-os != 'ubuntu-latest' + + - name: Run Go tests with coverage + run: go test -v -race -coverprofile=coverage.out ./... + if: matrix.runner-os == 'ubuntu-latest' + + - name: Generate coverage report + if: matrix.runner-os == 'ubuntu-latest' + run: | + go tool cover -html=coverage.out -o coverage.html + go tool cover -func=coverage.out + + - name: Upload coverage artifact + if: matrix.runner-os == 'ubuntu-latest' + uses: actions/upload-artifact@v6 + with: + name: go-coverage-report + path: coverage.html + + - name: Test binaries + run: | + ./dist/gei --version + ./dist/ado2gh --version + ./dist/bbs2gh --version + shell: bash + + go-lint: + name: Go Lint + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Setup Go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + + - name: golangci-lint + uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0 + with: + version: latest + args: --timeout=5m diff --git a/.gitignore b/.gitignore index 2b5c43dd3..6bbde164b 100644 --- a/.gitignore +++ b/.gitignore @@ -359,3 +359,9 @@ MigrationBackup/ /src/gei/Properties/launchSettings.json /src/OctoshiftCLI.IntegrationTests/Properties/launchSettings.json /src/ado2gh/Properties/launchSettings.json + +# Go coverage reports +coverage/ +*.out +coverage.html +/coverage.out diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 000000000..379ded43a --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,104 @@ +# golangci-lint configuration for gh-gei Go port +# See https://golangci-lint.run/usage/configuration/ +version: "2" + +run: + timeout: 5m + tests: true + modules-download-mode: readonly + +formatters: + enable: + - gofmt + - goimports + - gofumpt + +linters: + enable: + - govet # Reports suspicious constructs + - errcheck # Checks for unchecked errors + - staticcheck # Static analysis (includes gosimple, stylecheck) + - unused # Checks for unused code + - ineffassign # Detects ineffectual assignments + - bodyclose # Checks for HTTP response body close + - noctx # Finds HTTP requests without context + - misspell # Finds commonly misspelled English words + - unconvert # Removes unnecessary type conversions + - goconst # Finds repeated strings that could be constants + - gocyclo # Computes cyclomatic complexities + - revive # Fast, configurable, extensible linter + - gosec # Security-focused linter + - errname # Checks error naming + - errorlint # Finds misuses of errors + - whitespace # Detects leading/trailing whitespace + + settings: + gocyclo: + min-complexity: 15 + goconst: + min-len: 3 + min-occurrences: 3 + misspell: + locale: US + revive: + rules: + - name: exported + severity: warning + disabled: false + - name: package-comments + severity: warning + disabled: false + gosec: + excludes: + - G104 # Audit errors not checked (too noisy) + + exclusions: + presets: + - comments + - common-false-positives + - legacy + - std-error-handling + rules: + # Exclude some linters from running on tests files + - path: _test\.go + linters: + - gocyclo + - errcheck + - gosec + - revive + # Exclude error checks in main.go files (handled by cobra) + - path: cmd/.*/main\.go + text: "Error return value" + linters: + - errcheck + # Unused functions in skeleton main.go files will be wired up in future PRs + - path: cmd/.*/main\.go + linters: + - unused + # SA1029: context key type - will be replaced with proper type in later phases + - path: cmd/.*/main\.go + text: "SA1029" + linters: + - staticcheck + # G101 false positives on template constant names containing "Password", "Secret", etc. + - path: pkg/scriptgen/templates\.go + text: "G101" + linters: + - gosec + # Cyclomatic complexity for generate-script command will be addressed when refactoring + - path: cmd/gei/generate_script\.go + linters: + - gocyclo + # TLS InsecureSkipVerify is user-configurable for GHES with self-signed certs + - path: pkg/http/client\.go + text: "G402" + linters: + - gosec + +output: + formats: + text: + path: stdout + colors: true + print-issued-lines: true + print-linter-name: true diff --git a/GO_DEVELOPMENT.md b/GO_DEVELOPMENT.md new file mode 100644 index 000000000..09cbdde50 --- /dev/null +++ b/GO_DEVELOPMENT.md @@ -0,0 +1,292 @@ +# Go Port Development Guide + +This document describes the Go implementation of the GitHub Enterprise Importer CLI, which is being ported from C#/.NET to Go. + +## Status: Phase 1 Complete ✅ + +**Phase 1: Foundation** has been completed. The project structure, core packages, and build infrastructure are in place. + +### What's Working + +- ✅ Go module setup at repo root +- ✅ Directory structure (cmd/, pkg/, internal/) +- ✅ Core packages: logger, retry, env, filesystem +- ✅ Manual DI infrastructure with provider pattern +- ✅ Build system (justfile with Go targets) +- ✅ Linting configuration (golangci-lint) +- ✅ CI workflow for Go +- ✅ Three CLI skeleton binaries (gei, ado2gh, bbs2gh) +- ✅ Comprehensive test suite with 44.9% initial coverage + +## Project Structure + +``` +gh-gei/ +├── cmd/ # CLI entry points +│ ├── gei/ # GitHub to GitHub CLI +│ ├── ado2gh/ # Azure DevOps to GitHub CLI +│ └── bbs2gh/ # Bitbucket to GitHub CLI +├── pkg/ # Public library code +│ ├── app/ # DI container and app setup +│ ├── logger/ # Structured logging +│ ├── retry/ # Retry logic with exponential backoff +│ ├── env/ # Environment variable access +│ ├── filesystem/ # Filesystem operations +│ ├── models/ # Data models (TBD) +│ └── api/ # API clients (TBD in Phase 2) +│ ├── github/ +│ ├── ado/ +│ ├── bbs/ +│ ├── azure/ +│ └── aws/ +├── internal/ # Private application code (TBD) +│ ├── gei/ +│ ├── ado2gh/ +│ └── bbs2gh/ +├── src/ # Existing C# code (will be deleted later) +├── go.mod +├── justfile # Build tasks +├── .golangci.yml # Lint configuration +└── .github/workflows/ + ├── CI.yml # C# CI (existing) + └── go-ci.yml # Go CI (new) +``` + +## Development Workflow + +### Building + +```bash +# Build all three CLIs +just go-build + +# Or manually +go build -o dist/gei ./cmd/gei +go build -o dist/ado2gh ./cmd/ado2gh +go build -o dist/bbs2gh ./cmd/bbs2gh +``` + +### Testing + +```bash +# Run all tests +just go-test + +# Run tests with coverage +just go-test-coverage + +# Run tests with race detector +go test -race ./... +``` + +### Linting + +```bash +# Run golangci-lint +just go-lint + +# Check formatting +just go-format-check + +# Auto-format code +just go-format +``` + +### Cross-Platform Builds + +```bash +# Build for Linux +just go-publish-linux + +# Build for Windows +just go-publish-windows + +# Build for macOS +just go-publish-macos + +# Build for all platforms +just go-publish-all +``` + +## Core Packages + +### logger + +Provides structured logging with support for different log levels (debug, info, warning, error, verbose). Equivalent to C# `OctoLogger`. + +```go +import "github.com/github/gh-gei/pkg/logger" + +log := logger.New(verbose) +log.Info("Starting migration for repo %s", repoName) +log.Warning("Rate limit approaching") +log.Error(err) +log.Verbose("Detailed operation info") +``` + +### retry + +Implements retry logic with exponential backoff. Equivalent to C# `RetryPolicy` using Polly. + +```go +import "github.com/github/gh-gei/pkg/retry" + +policy := retry.New( + retry.WithMaxAttempts(5), + retry.WithDelay(1 * time.Second), +) + +err := policy.Execute(ctx, func() error { + return doSomething() +}) +``` + +### env + +Provides access to environment variables. Equivalent to C# `EnvironmentVariableProvider`. + +```go +import "github.com/github/gh-gei/pkg/env" + +envProvider := env.New() +pat := envProvider.TargetGitHubPAT() +skipVersion := envProvider.SkipVersionCheck() +``` + +### filesystem + +Provides filesystem operations. Equivalent to C# `FileSystemProvider`. + +```go +import "github.com/github/gh-gei/pkg/filesystem" + +fs := filesystem.New() +content, err := fs.ReadAllText("/path/to/file") +err = fs.WriteAllText("/path/to/output", data) +``` + +### app + +Provides manual dependency injection with a provider pattern (compatible with Wire if needed later). + +```go +import "github.com/github/gh-gei/pkg/app" + +cfg := &app.Config{ + Verbose: true, + RetryAttempts: 5, +} + +app := app.New(cfg) +app.Logger.Info("Application started") +``` + +## Design Decisions + +### Dependency Injection + +We use **manual DI** with a provider pattern. This keeps things simple while maintaining a structure that's compatible with Wire if we need it later. + +Provider functions in `pkg/app/app.go` are structured to be Wire-compatible: +```go +func provideLogger(cfg *Config) *logger.Logger { ... } +func provideRetryPolicy(cfg *Config) *retry.Policy { ... } +``` + +### Command Structure + +Using **Cobra** for CLI framework (industry standard, used by `gh` itself): +```go +rootCmd := &cobra.Command{ + Use: "gei", + Short: "GitHub Enterprise Importer CLI", +} +rootCmd.AddCommand(newMigrateRepoCmd()) +``` + +### Error Handling + +Go-idiomatic error handling with wrapped errors: +```go +if err := validateArgs(args); err != nil { + return fmt.Errorf("validation failed: %w", err) +} +``` + +### Testing + +Table-driven tests (Go idiom): +```go +tests := []struct { + name string + input interface{} + want interface{} + wantErr bool +}{ + {"success case", input1, output1, false}, + {"error case", input2, nil, true}, +} + +for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // test implementation + }) +} +``` + +## CI/CD + +### GitHub Actions Workflow + +The Go CI workflow (`.github/workflows/go-ci.yml`) runs on every PR and push to main: + +1. **Build & Test** (Linux, macOS, Windows) + - Format checking + - go vet + - Build binaries + - Run tests with race detector + - Generate coverage reports + +2. **Lint** (Linux only) + - golangci-lint with comprehensive ruleset + +### Coexistence with C# + +During the transition period: +- C# code stays in `src/` +- Both C# and Go CI workflows run +- Both implementations tested against integration tests +- Go version tagged as "beta" initially + +## Next Steps: Phase 2 - API Clients + +Phase 2 will implement the API clients: + +- [ ] GitHub API client (`pkg/api/github/`) +- [ ] Azure DevOps API client (`pkg/api/ado/`) +- [ ] Bitbucket Server API client (`pkg/api/bbs/`) +- [ ] Azure Blob Storage client (`pkg/api/azure/`) +- [ ] AWS S3 client (`pkg/api/aws/`) +- [ ] HTTP client infrastructure (retry, auth, logging) +- [ ] Unit tests for all API clients + +## Code Style + +Follow Go best practices: +- Run `gofmt` and `goimports` before committing +- Use table-driven tests +- Prefer explicit error handling over exceptions +- Use `context.Context` for cancellation +- Keep functions focused and testable +- Document public APIs with godoc comments + +## Resources + +- [Go Documentation](https://go.dev/doc/) +- [Effective Go](https://go.dev/doc/effective_go) +- [Cobra Documentation](https://cobra.dev/) +- [Project Plan](GO_PORT_PLAN.md) (full migration plan) + +## Questions? + +Refer to the main [CONTRIBUTING.md](CONTRIBUTING.md) for general contribution guidelines, or open a discussion in the GitHub Discussions tab. diff --git a/ThirdPartyNotices.txt b/ThirdPartyNotices.txt index 944a9e93b..0caa4a6e3 100644 --- a/ThirdPartyNotices.txt +++ b/ThirdPartyNotices.txt @@ -644,3 +644,50 @@ Licensed under MIT Available at https://licenses.nuget.org/MIT +License notice for github.com/avast/retry-go/v4 (v4.7.0) +------------------------------------ + +https://github.com/avast/retry-go + +Copyright (c) 2017 Avast + +Licensed under MIT + +Available at https://github.com/avast/retry-go/blob/master/LICENSE + + +License notice for github.com/spf13/cobra (v1.10.2) +------------------------------------ + +https://github.com/spf13/cobra + +Copyright (c) 2013 Steve Francia + +Licensed under Apache-2.0 + +Available at https://github.com/spf13/cobra/blob/main/LICENSE.txt + + +License notice for github.com/spf13/pflag (v1.0.9) +------------------------------------ + +https://github.com/spf13/pflag + +Copyright (c) 2012 Alex Ogier. Copyright (c) 2012 The Go Authors. + +Licensed under BSD-3-Clause + +Available at https://github.com/spf13/pflag/blob/master/LICENSE + + +License notice for github.com/inconshreveable/mousetrap (v1.1.0) +------------------------------------ + +https://github.com/inconshreveable/mousetrap + +Copyright (c) 2022 Alan Shreve + +Licensed under Apache-2.0 + +Available at https://github.com/inconshreveable/mousetrap/blob/master/LICENSE + diff --git a/cmd/ado2gh/main.go b/cmd/ado2gh/main.go new file mode 100644 index 000000000..5da395a52 --- /dev/null +++ b/cmd/ado2gh/main.go @@ -0,0 +1,92 @@ +package main + +import ( + "context" + "os" + + "github.com/github/gh-gei/pkg/env" + "github.com/github/gh-gei/pkg/logger" + "github.com/spf13/cobra" +) + +var ( + version = "dev" + verbose bool +) + +func main() { + if err := newRootCmd().Execute(); err != nil { + os.Exit(1) + } +} + +func newRootCmd() *cobra.Command { + rootCmd := &cobra.Command{ + Use: "ado2gh", + Short: "Azure DevOps to GitHub migration CLI", + Long: "Automate end-to-end Azure DevOps Repos to GitHub migrations.", + PersistentPreRun: func(cmd *cobra.Command, args []string) { + log := logger.New(verbose) + ctx := context.WithValue(cmd.Context(), "logger", log) + cmd.SetContext(ctx) + log.Debug("Execution started") + }, + SilenceUsage: true, + SilenceErrors: true, + } + + rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "Enable verbose logging") + rootCmd.Version = version + + // Add commands (will be implemented in phases) + // rootCmd.AddCommand(newMigrateRepoCmd()) + // rootCmd.AddCommand(newGenerateScriptCmd()) + // rootCmd.AddCommand(newInventoryReportCmd()) + // rootCmd.AddCommand(newRewirePipelineCmd()) + // rootCmd.AddCommand(newIntegrateBoardsCmd()) + // rootCmd.AddCommand(newAddTeamToRepoCmd()) + // rootCmd.AddCommand(newLockRepoCmd()) + // rootCmd.AddCommand(newDisableRepoCmd()) + // rootCmd.AddCommand(newConfigureAutoLinkCmd()) + // rootCmd.AddCommand(newShareServiceConnectionCmd()) + // rootCmd.AddCommand(newTestPipelinesCmd()) + // Shared commands from gei + // rootCmd.AddCommand(newWaitForMigrationCmd()) + // rootCmd.AddCommand(newAbortMigrationCmd()) + // rootCmd.AddCommand(newDownloadLogsCmd()) + // rootCmd.AddCommand(newGenerateMannequinCSVCmd()) + // rootCmd.AddCommand(newReclaimMannequinCmd()) + // rootCmd.AddCommand(newGrantMigratorRoleCmd()) + // rootCmd.AddCommand(newRevokeMigratorRoleCmd()) + // rootCmd.AddCommand(newCreateTeamCmd()) + + return rootCmd +} + +func getLogger(cmd *cobra.Command) *logger.Logger { + if log, ok := cmd.Context().Value("logger").(*logger.Logger); ok { + return log + } + return logger.New(false) +} + +func getEnvProvider() *env.Provider { + return env.New() +} + +func checkVersion(ctx context.Context, log *logger.Logger) { + envProvider := getEnvProvider() + if envProvider.SkipVersionCheck() == "true" || envProvider.SkipVersionCheck() == "1" { + log.Info("Skipped latest version check due to GEI_SKIP_VERSION_CHECK environment variable") + return + } + log.Info("You are running ado2gh CLI version %s", version) +} + +func checkGitHubStatus(ctx context.Context, log *logger.Logger) { + envProvider := getEnvProvider() + if envProvider.SkipStatusCheck() == "true" || envProvider.SkipStatusCheck() == "1" { + log.Info("Skipped GitHub status check due to GEI_SKIP_STATUS_CHECK environment variable") + return + } +} diff --git a/cmd/bbs2gh/main.go b/cmd/bbs2gh/main.go new file mode 100644 index 000000000..f3369aa09 --- /dev/null +++ b/cmd/bbs2gh/main.go @@ -0,0 +1,85 @@ +package main + +import ( + "context" + "os" + + "github.com/github/gh-gei/pkg/env" + "github.com/github/gh-gei/pkg/logger" + "github.com/spf13/cobra" +) + +var ( + version = "dev" + verbose bool +) + +func main() { + if err := newRootCmd().Execute(); err != nil { + os.Exit(1) + } +} + +func newRootCmd() *cobra.Command { + rootCmd := &cobra.Command{ + Use: "bbs2gh", + Short: "Bitbucket Server to GitHub migration CLI", + Long: "Migrate repositories from Bitbucket Server and Data Center to GitHub Enterprise Cloud.", + PersistentPreRun: func(cmd *cobra.Command, args []string) { + log := logger.New(verbose) + ctx := context.WithValue(cmd.Context(), "logger", log) + cmd.SetContext(ctx) + log.Debug("Execution started") + }, + SilenceUsage: true, + SilenceErrors: true, + } + + rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "Enable verbose logging") + rootCmd.Version = version + + // Add commands (will be implemented in phases) + // rootCmd.AddCommand(newMigrateRepoCmd()) + // rootCmd.AddCommand(newGenerateScriptCmd()) + // rootCmd.AddCommand(newInventoryReportCmd()) + // rootCmd.AddCommand(newMigrateCodeScanningAlertsCmd()) + // Shared commands from gei + // rootCmd.AddCommand(newWaitForMigrationCmd()) + // rootCmd.AddCommand(newAbortMigrationCmd()) + // rootCmd.AddCommand(newDownloadLogsCmd()) + // rootCmd.AddCommand(newGenerateMannequinCSVCmd()) + // rootCmd.AddCommand(newReclaimMannequinCmd()) + // rootCmd.AddCommand(newGrantMigratorRoleCmd()) + // rootCmd.AddCommand(newRevokeMigratorRoleCmd()) + // rootCmd.AddCommand(newCreateTeamCmd()) + + return rootCmd +} + +func getLogger(cmd *cobra.Command) *logger.Logger { + if log, ok := cmd.Context().Value("logger").(*logger.Logger); ok { + return log + } + return logger.New(false) +} + +func getEnvProvider() *env.Provider { + return env.New() +} + +func checkVersion(ctx context.Context, log *logger.Logger) { + envProvider := getEnvProvider() + if envProvider.SkipVersionCheck() == "true" || envProvider.SkipVersionCheck() == "1" { + log.Info("Skipped latest version check due to GEI_SKIP_VERSION_CHECK environment variable") + return + } + log.Info("You are running bbs2gh CLI version %s", version) +} + +func checkGitHubStatus(ctx context.Context, log *logger.Logger) { + envProvider := getEnvProvider() + if envProvider.SkipStatusCheck() == "true" || envProvider.SkipStatusCheck() == "1" { + log.Info("Skipped GitHub status check due to GEI_SKIP_STATUS_CHECK environment variable") + return + } +} diff --git a/cmd/gei/main.go b/cmd/gei/main.go new file mode 100644 index 000000000..90e6c300e --- /dev/null +++ b/cmd/gei/main.go @@ -0,0 +1,96 @@ +package main + +import ( + "context" + "os" + + "github.com/github/gh-gei/pkg/env" + "github.com/github/gh-gei/pkg/logger" + "github.com/spf13/cobra" +) + +var ( + version = "dev" + verbose bool +) + +func main() { + if err := newRootCmd().Execute(); err != nil { + os.Exit(1) + } +} + +func newRootCmd() *cobra.Command { + rootCmd := &cobra.Command{ + Use: "gei", + Short: "GitHub Enterprise Importer CLI", + Long: "CLI for migrating repositories between GitHub instances using GitHub Enterprise Importer.", + PersistentPreRun: func(cmd *cobra.Command, args []string) { + // Initialize logger + log := logger.New(verbose) + ctx := context.WithValue(cmd.Context(), "logger", log) + cmd.SetContext(ctx) + + log.Debug("Execution started") + }, + SilenceUsage: true, + SilenceErrors: true, + } + + rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "Enable verbose logging") + rootCmd.Version = version + + // Add commands (will be implemented in phases) + // rootCmd.AddCommand(newMigrateRepoCmd()) + // rootCmd.AddCommand(newMigrateOrgCmd()) + // rootCmd.AddCommand(newWaitForMigrationCmd()) + // rootCmd.AddCommand(newAbortMigrationCmd()) + // rootCmd.AddCommand(newDownloadLogsCmd()) + // rootCmd.AddCommand(newMigrateSecretAlertsCmd()) + // rootCmd.AddCommand(newMigrateCodeScanningAlertsCmd()) + // rootCmd.AddCommand(newGenerateMannequinCSVCmd()) + // rootCmd.AddCommand(newReclaimMannequinCmd()) + // rootCmd.AddCommand(newGrantMigratorRoleCmd()) + // rootCmd.AddCommand(newRevokeMigratorRoleCmd()) + // rootCmd.AddCommand(newCreateTeamCmd()) + + return rootCmd +} + +// getLogger retrieves the logger from the command context +func getLogger(cmd *cobra.Command) *logger.Logger { + if log, ok := cmd.Context().Value("logger").(*logger.Logger); ok { + return log + } + return logger.New(false) +} + +// getEnvProvider returns an environment provider +func getEnvProvider() *env.Provider { + return env.New() +} + +// checkVersion checks if a newer version is available +func checkVersion(ctx context.Context, log *logger.Logger) { + envProvider := getEnvProvider() + + if envProvider.SkipVersionCheck() == "true" || envProvider.SkipVersionCheck() == "1" { + log.Info("Skipped latest version check due to GEI_SKIP_VERSION_CHECK environment variable") + return + } + + // TODO: Implement version check + log.Info("You are running gei CLI version %s", version) +} + +// checkGitHubStatus checks if GitHub is experiencing incidents +func checkGitHubStatus(ctx context.Context, log *logger.Logger) { + envProvider := getEnvProvider() + + if envProvider.SkipStatusCheck() == "true" || envProvider.SkipStatusCheck() == "1" { + log.Info("Skipped GitHub status check due to GEI_SKIP_STATUS_CHECK environment variable") + return + } + + // TODO: Implement GitHub status check +} diff --git a/go.mod b/go.mod new file mode 100644 index 000000000..5f8be4a4a --- /dev/null +++ b/go.mod @@ -0,0 +1,13 @@ +module github.com/github/gh-gei + +go 1.25.4 + +require ( + github.com/avast/retry-go/v4 v4.7.0 + github.com/spf13/cobra v1.10.2 +) + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 000000000..486abd783 --- /dev/null +++ b/go.sum @@ -0,0 +1,20 @@ +github.com/avast/retry-go/v4 v4.7.0 h1:yjDs35SlGvKwRNSykujfjdMxMhMQQM0TnIjJaHB+Zio= +github.com/avast/retry-go/v4 v4.7.0/go.mod h1:ZMPDa3sY2bKgpLtap9JRUgk2yTAba7cgiFhqxY2Sg6Q= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +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/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +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/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +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/justfile b/justfile index 4875c8708..327432f7b 100644 --- a/justfile +++ b/justfile @@ -131,3 +131,72 @@ install-extensions: publish-linux cd gh-bbs2gh && gh extension install . && cd .. echo "Extensions installed successfully!" + +# ============================================================================ +# Go-based CLI targets +# ============================================================================ + +# Build Go binaries +go-build: + go build -o dist/gei ./cmd/gei + go build -o dist/ado2gh ./cmd/ado2gh + go build -o dist/bbs2gh ./cmd/bbs2gh + +# Run Go tests +go-test: + go test -v -race ./... + +# Run Go tests with coverage +go-test-coverage: + go test -v -race -coverprofile=coverage.out ./... + go tool cover -html=coverage.out -o coverage.html + go tool cover -func=coverage.out + +# Format Go code +go-format: + gofmt -w -s . + goimports -w . || echo "goimports not installed, skipping" + +# Check Go code formatting +go-format-check: + @test -z "$$(gofmt -l .)" || (echo "Go files need formatting, run 'just go-format'" && exit 1) + +# Lint Go code +go-lint: + golangci-lint run ./... || echo "golangci-lint not installed, skipping" + +# Build Go binaries for Linux +go-publish-linux: + mkdir -p dist/linux-x64 + GOOS=linux GOARCH=amd64 go build -o dist/linux-x64/gei-linux-amd64 ./cmd/gei + GOOS=linux GOARCH=amd64 go build -o dist/linux-x64/ado2gh-linux-amd64 ./cmd/ado2gh + GOOS=linux GOARCH=amd64 go build -o dist/linux-x64/bbs2gh-linux-amd64 ./cmd/bbs2gh + GOOS=linux GOARCH=arm64 go build -o dist/linux-arm64/gei-linux-arm64 ./cmd/gei + GOOS=linux GOARCH=arm64 go build -o dist/linux-arm64/ado2gh-linux-arm64 ./cmd/ado2gh + GOOS=linux GOARCH=arm64 go build -o dist/linux-arm64/bbs2gh-linux-arm64 ./cmd/bbs2gh + +# Build Go binaries for Windows +go-publish-windows: + mkdir -p dist/win-x64 dist/win-x86 + GOOS=windows GOARCH=amd64 go build -o dist/win-x64/gei-windows-amd64.exe ./cmd/gei + GOOS=windows GOARCH=amd64 go build -o dist/win-x64/ado2gh-windows-amd64.exe ./cmd/ado2gh + GOOS=windows GOARCH=amd64 go build -o dist/win-x64/bbs2gh-windows-amd64.exe ./cmd/bbs2gh + GOOS=windows GOARCH=386 go build -o dist/win-x86/gei-windows-386.exe ./cmd/gei + GOOS=windows GOARCH=386 go build -o dist/win-x86/ado2gh-windows-386.exe ./cmd/ado2gh + GOOS=windows GOARCH=386 go build -o dist/win-x86/bbs2gh-windows-386.exe ./cmd/bbs2gh + +# Build Go binaries for macOS +go-publish-macos: + mkdir -p dist/osx-x64 + GOOS=darwin GOARCH=amd64 go build -o dist/osx-x64/gei-darwin-amd64 ./cmd/gei + GOOS=darwin GOARCH=amd64 go build -o dist/osx-x64/ado2gh-darwin-amd64 ./cmd/ado2gh + GOOS=darwin GOARCH=amd64 go build -o dist/osx-x64/bbs2gh-darwin-amd64 ./cmd/bbs2gh + +# Build Go binaries for all platforms +go-publish-all: go-publish-linux go-publish-windows go-publish-macos + +# Run Go CI pipeline +go-ci: go-format-check go-build go-test + +# Run both C# and Go CI pipelines +ci-all: ci go-ci diff --git a/pkg/app/app.go b/pkg/app/app.go new file mode 100644 index 000000000..58e2af7a4 --- /dev/null +++ b/pkg/app/app.go @@ -0,0 +1,78 @@ +package app + +import ( + "github.com/github/gh-gei/pkg/env" + "github.com/github/gh-gei/pkg/filesystem" + "github.com/github/gh-gei/pkg/logger" + "github.com/github/gh-gei/pkg/retry" +) + +// App contains all the dependencies for the CLI application +// This provides manual dependency injection with a provider pattern +type App struct { + Logger *logger.Logger + Env *env.Provider + FileSystem *filesystem.Provider + Retry *retry.Policy + // API clients will be added in Phase 2 + // GitHubClient *github.Client + // ADOClient *ado.Client + // BBSClient *bbs.Client + // AzureClient *azure.Client + // AWSClient *aws.Client +} + +// Config contains configuration for initializing the app +type Config struct { + Verbose bool + LogFile string + RetryAttempts uint +} + +// New creates a new App with all dependencies initialized +// This is the main provider function for dependency injection +func New(cfg *Config) *App { + // Initialize core dependencies + log := provideLogger(cfg) + envProvider := provideEnvProvider() + fsProvider := provideFileSystemProvider() + retryPolicy := provideRetryPolicy(cfg) + + return &App{ + Logger: log, + Env: envProvider, + FileSystem: fsProvider, + Retry: retryPolicy, + } +} + +// Provider functions - these are structured to be compatible with Wire +// if we decide to adopt it later + +func provideLogger(cfg *Config) *logger.Logger { + // TODO: Handle log file if specified + return logger.New(cfg.Verbose) +} + +func provideEnvProvider() *env.Provider { + return env.New() +} + +func provideFileSystemProvider() *filesystem.Provider { + return filesystem.New() +} + +func provideRetryPolicy(cfg *Config) *retry.Policy { + attempts := cfg.RetryAttempts + if attempts == 0 { + attempts = 3 // default + } + return retry.New(retry.WithMaxAttempts(attempts)) +} + +// Additional provider functions will be added as we implement API clients: +// - provideGitHubClient +// - provideADOClient +// - provideBBSClient +// - provideAzureClient +// - provideAWSClient diff --git a/pkg/app/app_test.go b/pkg/app/app_test.go new file mode 100644 index 000000000..07e7f817b --- /dev/null +++ b/pkg/app/app_test.go @@ -0,0 +1,50 @@ +package app_test + +import ( + "testing" + + "github.com/github/gh-gei/pkg/app" +) + +func TestNew(t *testing.T) { + cfg := &app.Config{ + Verbose: true, + RetryAttempts: 5, + } + + a := app.New(cfg) + + if a == nil { + t.Fatal("expected app to be created, got nil") + } + + if a.Logger == nil { + t.Error("expected logger to be initialized") + } + + if a.Env == nil { + t.Error("expected env provider to be initialized") + } + + if a.FileSystem == nil { + t.Error("expected filesystem provider to be initialized") + } + + if a.Retry == nil { + t.Error("expected retry policy to be initialized") + } +} + +func TestNew_WithDefaults(t *testing.T) { + cfg := &app.Config{} + a := app.New(cfg) + + if a == nil { + t.Fatal("expected app to be created with defaults, got nil") + } + + // All dependencies should still be initialized even with empty config + if a.Logger == nil || a.Env == nil || a.FileSystem == nil || a.Retry == nil { + t.Error("expected all dependencies to be initialized with defaults") + } +} diff --git a/pkg/env/env.go b/pkg/env/env.go new file mode 100644 index 000000000..8c858e6e9 --- /dev/null +++ b/pkg/env/env.go @@ -0,0 +1,95 @@ +package env + +import "os" + +// Provider provides access to environment variables +// Equivalent to C# EnvironmentVariableProvider +type Provider struct{} + +// New creates a new environment variable provider +func New() *Provider { + return &Provider{} +} + +// SourceGitHubPAT returns the GH_SOURCE_PAT environment variable +func (p *Provider) SourceGitHubPAT() string { + return os.Getenv("GH_SOURCE_PAT") +} + +// TargetGitHubPAT returns the GH_PAT environment variable +func (p *Provider) TargetGitHubPAT() string { + return os.Getenv("GH_PAT") +} + +// ADO_PAT returns the ADO_PAT environment variable +func (p *Provider) ADOPAT() string { + return os.Getenv("ADO_PAT") +} + +// BBSUsername returns the BBS_USERNAME environment variable +func (p *Provider) BBSUsername() string { + return os.Getenv("BBS_USERNAME") +} + +// BBSPassword returns the BBS_PASSWORD environment variable +func (p *Provider) BBSPassword() string { + return os.Getenv("BBS_PASSWORD") +} + +// AzureStorageConnectionString returns the AZURE_STORAGE_CONNECTION_STRING environment variable +func (p *Provider) AzureStorageConnectionString() string { + return os.Getenv("AZURE_STORAGE_CONNECTION_STRING") +} + +// SkipVersionCheck returns the GEI_SKIP_VERSION_CHECK environment variable +func (p *Provider) SkipVersionCheck() string { + return os.Getenv("GEI_SKIP_VERSION_CHECK") +} + +// SkipStatusCheck returns the GEI_SKIP_STATUS_CHECK environment variable +func (p *Provider) SkipStatusCheck() string { + return os.Getenv("GEI_SKIP_STATUS_CHECK") +} + +// AWSAccessKeyID returns the AWS_ACCESS_KEY_ID environment variable +func (p *Provider) AWSAccessKeyID() string { + return os.Getenv("AWS_ACCESS_KEY_ID") +} + +// AWSSecretAccessKey returns the AWS_SECRET_ACCESS_KEY environment variable +func (p *Provider) AWSSecretAccessKey() string { + return os.Getenv("AWS_SECRET_ACCESS_KEY") +} + +// AWSSessionToken returns the AWS_SESSION_TOKEN environment variable +func (p *Provider) AWSSessionToken() string { + return os.Getenv("AWS_SESSION_TOKEN") +} + +// AWSRegion returns the AWS_REGION environment variable +func (p *Provider) AWSRegion() string { + return os.Getenv("AWS_REGION") +} + +// AWSBucketName returns the AWS_BUCKET_NAME environment variable +func (p *Provider) AWSBucketName() string { + return os.Getenv("AWS_BUCKET_NAME") +} + +// GitHubOwnedStorageMultipartMebibytes returns the GITHUB_OWNED_STORAGE_MULTIPART_MEBIBYTES environment variable +func (p *Provider) GitHubOwnedStorageMultipartMebibytes() string { + return os.Getenv("GITHUB_OWNED_STORAGE_MULTIPART_MEBIBYTES") +} + +// GetOrDefault returns the environment variable value or a default +func (p *Provider) GetOrDefault(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue +} + +// Set sets an environment variable (useful for testing) +func (p *Provider) Set(key, value string) error { + return os.Setenv(key, value) +} diff --git a/pkg/filesystem/filesystem.go b/pkg/filesystem/filesystem.go new file mode 100644 index 000000000..c6be9003c --- /dev/null +++ b/pkg/filesystem/filesystem.go @@ -0,0 +1,130 @@ +package filesystem + +import ( + "io" + "os" + "path/filepath" +) + +// Provider provides filesystem operations +// Equivalent to C# FileSystemProvider +type Provider struct{} + +// New creates a new filesystem provider +func New() *Provider { + return &Provider{} +} + +// ReadAllText reads all text from a file +func (p *Provider) ReadAllText(path string) (string, error) { + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + return string(data), nil +} + +// ReadAllBytes reads all bytes from a file +func (p *Provider) ReadAllBytes(path string) ([]byte, error) { + return os.ReadFile(path) +} + +// WriteAllText writes text to a file +func (p *Provider) WriteAllText(path, content string) error { + return os.WriteFile(path, []byte(content), 0o600) +} + +// WriteAllBytes writes bytes to a file +func (p *Provider) WriteAllBytes(path string, data []byte) error { + return os.WriteFile(path, data, 0o600) +} + +// FileExists checks if a file exists +func (p *Provider) FileExists(path string) bool { + info, err := os.Stat(path) + if err != nil { + return false + } + return !info.IsDir() +} + +// DirectoryExists checks if a directory exists +func (p *Provider) DirectoryExists(path string) bool { + info, err := os.Stat(path) + if err != nil { + return false + } + return info.IsDir() +} + +// CreateDirectory creates a directory and all parent directories +func (p *Provider) CreateDirectory(path string) error { + return os.MkdirAll(path, 0o755) +} + +// DeleteFile deletes a file +func (p *Provider) DeleteFile(path string) error { + return os.Remove(path) +} + +// DeleteDirectory deletes a directory and all its contents +func (p *Provider) DeleteDirectory(path string) error { + return os.RemoveAll(path) +} + +// GetTempPath returns the system temp directory +func (p *Provider) GetTempPath() string { + return os.TempDir() +} + +// GetTempFileName creates a temporary file and returns its path +func (p *Provider) GetTempFileName() (string, error) { + file, err := os.CreateTemp("", "gei-*.tmp") + if err != nil { + return "", err + } + defer file.Close() + return file.Name(), nil +} + +// CopyFile copies a file from source to destination +func (p *Provider) CopyFile(src, dst string) error { + source, err := os.Open(src) + if err != nil { + return err + } + defer source.Close() + + destination, err := os.Create(dst) + if err != nil { + return err + } + defer destination.Close() + + _, err = io.Copy(destination, source) + return err +} + +// GetFileSize returns the size of a file in bytes +func (p *Provider) GetFileSize(path string) (int64, error) { + info, err := os.Stat(path) + if err != nil { + return 0, err + } + return info.Size(), nil +} + +// GetFileName returns the filename from a path +func (p *Provider) GetFileName(path string) string { + return filepath.Base(path) +} + +// GetDirectoryName returns the directory name from a path +func (p *Provider) GetDirectoryName(path string) string { + return filepath.Dir(path) +} + +// Combine joins path elements +func (p *Provider) Combine(paths ...string) string { + return filepath.Join(paths...) +} diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go new file mode 100644 index 000000000..47f12a072 --- /dev/null +++ b/pkg/logger/logger.go @@ -0,0 +1,98 @@ +package logger + +import ( + "fmt" + "io" + "os" + "sync/atomic" + "time" +) + +// Logger provides structured logging capabilities for the CLI +// Equivalent to C# OctoLogger +type Logger struct { + verbose bool + output io.Writer + verboseOutput io.Writer + warningCount atomic.Int32 +} + +// New creates a new Logger instance +func New(verbose bool, outputs ...io.Writer) *Logger { + output := io.Writer(os.Stdout) + verboseOutput := io.Writer(os.Stdout) + + if len(outputs) > 0 && outputs[0] != nil { + output = outputs[0] + } + if len(outputs) > 1 && outputs[1] != nil { + verboseOutput = outputs[1] + } + + return &Logger{ + verbose: verbose, + output: output, + verboseOutput: verboseOutput, + } +} + +// Debug logs a debug message +func (l *Logger) Debug(format string, args ...interface{}) { + l.log("[DEBUG]", format, args...) +} + +// Info logs an informational message +func (l *Logger) Info(format string, args ...interface{}) { + l.log("[INFO]", format, args...) +} + +// Success logs a success message +func (l *Logger) Success(format string, args ...interface{}) { + l.log("[SUCCESS]", format, args...) +} + +// Warning logs a warning message and increments warning count +func (l *Logger) Warning(format string, args ...interface{}) { + l.warningCount.Add(1) + l.log("[WARNING]", format, args...) +} + +// Error logs an error +func (l *Logger) Error(err error) { + if err != nil { + l.log("[ERROR]", "%v", err) + } +} + +// Errorf logs a formatted error message +func (l *Logger) Errorf(format string, args ...interface{}) { + l.log("[ERROR]", format, args...) +} + +// Verbose logs a verbose message (only when verbose mode is enabled) +func (l *Logger) Verbose(format string, args ...interface{}) { + if l.verbose { + msg := fmt.Sprintf(format, args...) + timestamp := time.Now().Format("2006-01-02 15:04:05") + fmt.Fprintf(l.verboseOutput, "[VERBOSE] %s: %s\n", timestamp, msg) + } +} + +// GetWarningCount returns the number of warnings logged +func (l *Logger) GetWarningCount() int { + return int(l.warningCount.Load()) +} + +// LogWarningCount logs the total warning count if warnings occurred +func (l *Logger) LogWarningCount() { + count := l.GetWarningCount() + if count > 0 { + l.Warning("Total warnings: %d", count) + } +} + +func (l *Logger) log(level, format string, args ...interface{}) { + msg := fmt.Sprintf(format, args...) + timestamp := time.Now().Format("2006-01-02 15:04:05") + fmt.Fprintf(l.output, "%s %s: %s\n", level, timestamp, msg) +} diff --git a/pkg/logger/logger_test.go b/pkg/logger/logger_test.go new file mode 100644 index 000000000..32adfefbf --- /dev/null +++ b/pkg/logger/logger_test.go @@ -0,0 +1,111 @@ +package logger_test + +import ( + "bytes" + "errors" + "strings" + "testing" + + "github.com/github/gh-gei/pkg/logger" +) + +func TestLogger_Info(t *testing.T) { + var buf bytes.Buffer + log := logger.New(false, &buf) + + log.Info("test message") + + output := buf.String() + if !strings.Contains(output, "[INFO]") { + t.Errorf("expected [INFO] in output, got: %s", output) + } + if !strings.Contains(output, "test message") { + t.Errorf("expected 'test message' in output, got: %s", output) + } +} + +func TestLogger_Warning(t *testing.T) { + var buf bytes.Buffer + log := logger.New(false, &buf) + + log.Warning("warning 1") + log.Warning("warning 2") + + if log.GetWarningCount() != 2 { + t.Errorf("expected 2 warnings, got: %d", log.GetWarningCount()) + } + + output := buf.String() + if !strings.Contains(output, "[WARNING]") { + t.Errorf("expected [WARNING] in output, got: %s", output) + } +} + +func TestLogger_Error(t *testing.T) { + var buf bytes.Buffer + log := logger.New(false, &buf) + + testErr := errors.New("test error") + log.Error(testErr) + + output := buf.String() + if !strings.Contains(output, "[ERROR]") { + t.Errorf("expected [ERROR] in output, got: %s", output) + } + if !strings.Contains(output, "test error") { + t.Errorf("expected 'test error' in output, got: %s", output) + } +} + +func TestLogger_Verbose(t *testing.T) { + tests := []struct { + name string + verbose bool + message string + expectInOutput bool + }{ + { + name: "verbose enabled", + verbose: true, + message: "verbose message", + expectInOutput: true, + }, + { + name: "verbose disabled", + verbose: false, + message: "verbose message", + expectInOutput: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var buf bytes.Buffer + log := logger.New(tt.verbose, &buf, &buf) + + log.Verbose("%s", tt.message) + + output := buf.String() + contains := strings.Contains(output, tt.message) + + if tt.expectInOutput && !contains { + t.Errorf("expected message in output when verbose=%v, got: %s", tt.verbose, output) + } + if !tt.expectInOutput && contains { + t.Errorf("expected no message in output when verbose=%v, got: %s", tt.verbose, output) + } + }) + } +} + +func TestLogger_ErrorWithNil(t *testing.T) { + var buf bytes.Buffer + log := logger.New(false, &buf) + + log.Error(nil) + + output := buf.String() + if output != "" { + t.Errorf("expected no output for nil error, got: %s", output) + } +} diff --git a/pkg/retry/retry.go b/pkg/retry/retry.go new file mode 100644 index 000000000..3934b4937 --- /dev/null +++ b/pkg/retry/retry.go @@ -0,0 +1,104 @@ +package retry + +import ( + "context" + "fmt" + "time" + + "github.com/avast/retry-go/v4" +) + +// Policy provides retry capabilities for operations +// Equivalent to C# RetryPolicy using Polly +type Policy struct { + maxAttempts uint + delay time.Duration + maxDelay time.Duration +} + +// Option is a functional option for configuring the retry policy +type Option func(*Policy) + +// WithMaxAttempts sets the maximum number of retry attempts +func WithMaxAttempts(attempts uint) Option { + return func(p *Policy) { + p.maxAttempts = attempts + } +} + +// WithDelay sets the initial delay between retries +func WithDelay(delay time.Duration) Option { + return func(p *Policy) { + p.delay = delay + } +} + +// WithMaxDelay sets the maximum delay between retries +func WithMaxDelay(maxDelay time.Duration) Option { + return func(p *Policy) { + p.maxDelay = maxDelay + } +} + +// New creates a new retry policy with the given options +func New(opts ...Option) *Policy { + p := &Policy{ + maxAttempts: 3, + delay: 1 * time.Second, + maxDelay: 30 * time.Second, + } + + for _, opt := range opts { + opt(p) + } + + return p +} + +// Execute executes the given function with retry logic +func (p *Policy) Execute(ctx context.Context, fn func() error) error { + return retry.Do( + fn, + retry.Attempts(p.maxAttempts), + retry.Delay(p.delay), + retry.MaxDelay(p.maxDelay), + retry.Context(ctx), + retry.DelayType(retry.BackOffDelay), + retry.OnRetry(func(n uint, err error) { + // Could add logging here if needed + }), + ) +} + +// ExecuteWithResult executes the given function with retry logic and returns a result +func ExecuteWithResult[T any](ctx context.Context, p *Policy, fn func() (T, error)) (T, error) { + var result T + var lastErr error + + err := p.Execute(ctx, func() error { + var err error + result, err = fn() + lastErr = err + return err + }) + if err != nil { + return result, fmt.Errorf("retry failed after %d attempts: %w", p.maxAttempts, lastErr) + } + + return result, nil +} + +// HTTPRetryableStatusCodes returns HTTP status codes that should trigger a retry +func HTTPRetryableStatusCodes() []int { + return []int{408, 429, 500, 502, 503, 504} +} + +// IsRetryableHTTPStatus checks if an HTTP status code should trigger a retry +func IsRetryableHTTPStatus(statusCode int) bool { + for _, code := range HTTPRetryableStatusCodes() { + if statusCode == code { + return true + } + } + return false +} diff --git a/pkg/retry/retry_test.go b/pkg/retry/retry_test.go new file mode 100644 index 000000000..f8f8c19fc --- /dev/null +++ b/pkg/retry/retry_test.go @@ -0,0 +1,159 @@ +package retry_test + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/github/gh-gei/pkg/retry" +) + +func TestRetry_Execute_Success(t *testing.T) { + policy := retry.New() + ctx := context.Background() + + callCount := 0 + err := policy.Execute(ctx, func() error { + callCount++ + return nil + }) + if err != nil { + t.Errorf("expected no error, got: %v", err) + } + if callCount != 1 { + t.Errorf("expected 1 call, got: %d", callCount) + } +} + +func TestRetry_Execute_SuccessAfterRetry(t *testing.T) { + policy := retry.New(retry.WithMaxAttempts(3), retry.WithDelay(10*time.Millisecond)) + ctx := context.Background() + + callCount := 0 + err := policy.Execute(ctx, func() error { + callCount++ + if callCount < 2 { + return errors.New("temporary error") + } + return nil + }) + if err != nil { + t.Errorf("expected no error after retry, got: %v", err) + } + if callCount != 2 { + t.Errorf("expected 2 calls, got: %d", callCount) + } +} + +func TestRetry_Execute_MaxAttemptsExceeded(t *testing.T) { + policy := retry.New(retry.WithMaxAttempts(3), retry.WithDelay(10*time.Millisecond)) + ctx := context.Background() + + callCount := 0 + testErr := errors.New("persistent error") + + err := policy.Execute(ctx, func() error { + callCount++ + return testErr + }) + + if err == nil { + t.Error("expected error after max attempts, got nil") + } + if callCount != 3 { + t.Errorf("expected 3 calls, got: %d", callCount) + } +} + +func TestRetry_ExecuteWithResult(t *testing.T) { + policy := retry.New(retry.WithDelay(10 * time.Millisecond)) + ctx := context.Background() + + callCount := 0 + result, err := retry.ExecuteWithResult(ctx, policy, func() (string, error) { + callCount++ + if callCount < 2 { + return "", errors.New("temporary error") + } + return "success", nil + }) + if err != nil { + t.Errorf("expected no error, got: %v", err) + } + if result != "success" { + t.Errorf("expected 'success', got: %s", result) + } + if callCount != 2 { + t.Errorf("expected 2 calls, got: %d", callCount) + } +} + +func TestRetry_ContextCancellation(t *testing.T) { + policy := retry.New(retry.WithMaxAttempts(10), retry.WithDelay(100*time.Millisecond)) + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + + err := policy.Execute(ctx, func() error { + return errors.New("error") + }) + + if err == nil { + t.Error("expected context cancellation error") + } +} + +func TestRetry_IsRetryableHTTPStatus(t *testing.T) { + tests := []struct { + statusCode int + want bool + }{ + {200, false}, + {201, false}, + {400, false}, + {401, false}, + {403, false}, + {404, false}, + {408, true}, // Request Timeout + {429, true}, // Too Many Requests + {500, true}, // Internal Server Error + {502, true}, // Bad Gateway + {503, true}, // Service Unavailable + {504, true}, // Gateway Timeout + } + + for _, tt := range tests { + t.Run(string(rune(tt.statusCode)), func(t *testing.T) { + got := retry.IsRetryableHTTPStatus(tt.statusCode) + if got != tt.want { + t.Errorf("IsRetryableHTTPStatus(%d) = %v, want %v", tt.statusCode, got, tt.want) + } + }) + } +} + +func TestRetry_Options(t *testing.T) { + policy := retry.New( + retry.WithMaxAttempts(5), + retry.WithDelay(2*time.Second), + retry.WithMaxDelay(60*time.Second), + ) + + // We can't directly test private fields, but we can test the behavior + ctx := context.Background() + callCount := 0 + + err := policy.Execute(ctx, func() error { + callCount++ + if callCount < 5 { + return errors.New("error") + } + return nil + }) + if err != nil { + t.Errorf("expected success after retries, got: %v", err) + } + if callCount != 5 { + t.Errorf("expected 5 calls, got: %d", callCount) + } +}