Skip to content

Commit bad2e82

Browse files
committed
Phase 3: GitHub API client + all shared commands (Go port)
This PR implements the core GitHub API client and all shared gei subcommands in Go, building on the project skeleton (PR 1) and HTTP/auth foundation (PR 2). ## GitHub API client (pkg/github/) - Custom GraphQL client with pagination, secondary rate limit detection and retry, and navigateJSON() dot-path traversal - 32+ migration API methods via google/go-github REST client: organization, repository, team, mannequin, migration lifecycle - Comprehensive models (models.go) for all API response types ## Shared command infrastructure (internal/cmdutil/) - UserError type for user-friendly error messages - Flag validators: ValidatePaired, ValidateAtLeastOne, ValidateExactlyOne - Migration status constants and classifier (pkg/migration/) ## Commands (cmd/gei/) - wait-for-migration: poll migration status with configurable interval - abort-migration: cancel in-progress migrations - download-logs: fetch and save migration log archives - grant-migrator-role / revoke-migrator-role: manage migration permissions - create-team: create GitHub teams with idempotent slug handling - generate-mannequin-csv: export mannequin data to CSV - reclaim-mannequin: reclaim mannequins from CSV or individual args ## Supporting packages - pkg/download/: migration log download service with temp file cleanup - pkg/mannequin/: mannequin reclamation service with CSV parsing - pkg/version/: version checker against GitHub releases - pkg/status/: GitHub status page availability check ## Testing - 170+ tests across all packages, all passing with -race - External test package convention (package foo_test) - httptest-based test servers for API client tests ## Tooling - golangci-lint v2 configured, 0 issues - Go 1.25.4 added to mise.toml alongside dotnet 8.0.410 - Go binary names added to .gitignore
1 parent 2b6282d commit bad2e82

51 files changed

Lines changed: 9809 additions & 257 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,11 @@ MigrationBackup/
360360
/src/OctoshiftCLI.IntegrationTests/Properties/launchSettings.json
361361
/src/ado2gh/Properties/launchSettings.json
362362

363+
# Go binaries (built from cmd/)
364+
/gei
365+
/ado2gh
366+
/bbs2gh
367+
363368
# Go coverage reports
364369
coverage/
365370
*.out

.golangci.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,15 @@ linters:
8080
text: "SA1029"
8181
linters:
8282
- staticcheck
83+
# G101 false positives on template constant names containing "Password", "Secret", etc.
84+
- path: pkg/scriptgen/templates\.go
85+
text: "G101"
86+
linters:
87+
- gosec
88+
# Cyclomatic complexity for generate-script command will be addressed when refactoring
89+
- path: cmd/gei/generate_script\.go
90+
linters:
91+
- gocyclo
8392

8493
output:
8594
formats:

cmd/ado2gh/main.go

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,22 @@ package main
22

33
import (
44
"context"
5+
"net/http"
56
"os"
7+
"strings"
68

79
"github.com/github/gh-gei/pkg/env"
810
"github.com/github/gh-gei/pkg/logger"
11+
"github.com/github/gh-gei/pkg/status"
12+
versionpkg "github.com/github/gh-gei/pkg/version"
913
"github.com/spf13/cobra"
1014
)
1115

16+
// contextKey is an unexported type for context keys in this package.
17+
type contextKey string
18+
19+
const loggerKey contextKey = "logger"
20+
1221
var (
1322
version = "dev"
1423
verbose bool
@@ -25,11 +34,16 @@ func newRootCmd() *cobra.Command {
2534
Use: "ado2gh",
2635
Short: "Azure DevOps to GitHub migration CLI",
2736
Long: "Automate end-to-end Azure DevOps Repos to GitHub migrations.",
28-
PersistentPreRun: func(cmd *cobra.Command, args []string) {
37+
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
2938
log := logger.New(verbose)
30-
ctx := context.WithValue(cmd.Context(), "logger", log)
39+
ctx := context.WithValue(cmd.Context(), loggerKey, log)
3140
cmd.SetContext(ctx)
3241
log.Debug("Execution started")
42+
43+
checkVersion(ctx, log)
44+
checkGitHubStatus(ctx, log)
45+
46+
return nil
3347
},
3448
SilenceUsage: true,
3549
SilenceErrors: true,
@@ -64,7 +78,7 @@ func newRootCmd() *cobra.Command {
6478
}
6579

6680
func getLogger(cmd *cobra.Command) *logger.Logger {
67-
if log, ok := cmd.Context().Value("logger").(*logger.Logger); ok {
81+
if log, ok := cmd.Context().Value(loggerKey).(*logger.Logger); ok {
6882
return log
6983
}
7084
return logger.New(false)
@@ -76,17 +90,41 @@ func getEnvProvider() *env.Provider {
7690

7791
func checkVersion(ctx context.Context, log *logger.Logger) {
7892
envProvider := getEnvProvider()
79-
if envProvider.SkipVersionCheck() == "true" || envProvider.SkipVersionCheck() == "1" {
93+
skip := envProvider.SkipVersionCheck()
94+
if strings.EqualFold(skip, "true") || skip == "1" {
8095
log.Info("Skipped latest version check due to GEI_SKIP_VERSION_CHECK environment variable")
8196
return
8297
}
83-
log.Info("You are running ado2gh CLI version %s", version)
98+
99+
checker := versionpkg.NewChecker(&http.Client{}, log, version)
100+
isLatest, err := checker.IsLatest(ctx)
101+
if err != nil {
102+
log.Debug("Version check failed: %v", err)
103+
return
104+
}
105+
106+
if !isLatest {
107+
latest, _ := checker.GetLatestVersion(ctx)
108+
log.Info("New version available: %s", latest)
109+
log.Info("You are running ado2gh CLI version %s", version)
110+
}
84111
}
85112

86113
func checkGitHubStatus(ctx context.Context, log *logger.Logger) {
87114
envProvider := getEnvProvider()
88-
if envProvider.SkipStatusCheck() == "true" || envProvider.SkipStatusCheck() == "1" {
115+
skip := envProvider.SkipStatusCheck()
116+
if strings.EqualFold(skip, "true") || skip == "1" {
89117
log.Info("Skipped GitHub status check due to GEI_SKIP_STATUS_CHECK environment variable")
90118
return
91119
}
120+
121+
count, err := status.GetUnresolvedIncidentsCount(ctx, &http.Client{}, "https://www.githubstatus.com")
122+
if err != nil {
123+
log.Debug("GitHub status check failed: %v", err)
124+
return
125+
}
126+
127+
if count > 0 {
128+
log.Warning("GitHub is currently experiencing %d incident(s). Check https://www.githubstatus.com for details.", count)
129+
}
92130
}

cmd/bbs2gh/main.go

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,22 @@ package main
22

33
import (
44
"context"
5+
"net/http"
56
"os"
7+
"strings"
68

79
"github.com/github/gh-gei/pkg/env"
810
"github.com/github/gh-gei/pkg/logger"
11+
"github.com/github/gh-gei/pkg/status"
12+
versionpkg "github.com/github/gh-gei/pkg/version"
913
"github.com/spf13/cobra"
1014
)
1115

16+
// contextKey is an unexported type for context keys in this package.
17+
type contextKey string
18+
19+
const loggerKey contextKey = "logger"
20+
1221
var (
1322
version = "dev"
1423
verbose bool
@@ -25,11 +34,16 @@ func newRootCmd() *cobra.Command {
2534
Use: "bbs2gh",
2635
Short: "Bitbucket Server to GitHub migration CLI",
2736
Long: "Migrate repositories from Bitbucket Server and Data Center to GitHub Enterprise Cloud.",
28-
PersistentPreRun: func(cmd *cobra.Command, args []string) {
37+
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
2938
log := logger.New(verbose)
30-
ctx := context.WithValue(cmd.Context(), "logger", log)
39+
ctx := context.WithValue(cmd.Context(), loggerKey, log)
3140
cmd.SetContext(ctx)
3241
log.Debug("Execution started")
42+
43+
checkVersion(ctx, log)
44+
checkGitHubStatus(ctx, log)
45+
46+
return nil
3347
},
3448
SilenceUsage: true,
3549
SilenceErrors: true,
@@ -57,7 +71,7 @@ func newRootCmd() *cobra.Command {
5771
}
5872

5973
func getLogger(cmd *cobra.Command) *logger.Logger {
60-
if log, ok := cmd.Context().Value("logger").(*logger.Logger); ok {
74+
if log, ok := cmd.Context().Value(loggerKey).(*logger.Logger); ok {
6175
return log
6276
}
6377
return logger.New(false)
@@ -69,17 +83,41 @@ func getEnvProvider() *env.Provider {
6983

7084
func checkVersion(ctx context.Context, log *logger.Logger) {
7185
envProvider := getEnvProvider()
72-
if envProvider.SkipVersionCheck() == "true" || envProvider.SkipVersionCheck() == "1" {
86+
skip := envProvider.SkipVersionCheck()
87+
if strings.EqualFold(skip, "true") || skip == "1" {
7388
log.Info("Skipped latest version check due to GEI_SKIP_VERSION_CHECK environment variable")
7489
return
7590
}
76-
log.Info("You are running bbs2gh CLI version %s", version)
91+
92+
checker := versionpkg.NewChecker(&http.Client{}, log, version)
93+
isLatest, err := checker.IsLatest(ctx)
94+
if err != nil {
95+
log.Debug("Version check failed: %v", err)
96+
return
97+
}
98+
99+
if !isLatest {
100+
latest, _ := checker.GetLatestVersion(ctx)
101+
log.Info("New version available: %s", latest)
102+
log.Info("You are running bbs2gh CLI version %s", version)
103+
}
77104
}
78105

79106
func checkGitHubStatus(ctx context.Context, log *logger.Logger) {
80107
envProvider := getEnvProvider()
81-
if envProvider.SkipStatusCheck() == "true" || envProvider.SkipStatusCheck() == "1" {
108+
skip := envProvider.SkipStatusCheck()
109+
if strings.EqualFold(skip, "true") || skip == "1" {
82110
log.Info("Skipped GitHub status check due to GEI_SKIP_STATUS_CHECK environment variable")
83111
return
84112
}
113+
114+
count, err := status.GetUnresolvedIncidentsCount(ctx, &http.Client{}, "https://www.githubstatus.com")
115+
if err != nil {
116+
log.Debug("GitHub status check failed: %v", err)
117+
return
118+
}
119+
120+
if count > 0 {
121+
log.Warning("GitHub is currently experiencing %d incident(s). Check https://www.githubstatus.com for details.", count)
122+
}
85123
}

cmd/gei/abort_migration.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"strings"
6+
7+
"github.com/github/gh-gei/internal/cmdutil"
8+
"github.com/github/gh-gei/pkg/logger"
9+
"github.com/spf13/cobra"
10+
)
11+
12+
// migrationAborter is the consumer-defined interface for aborting migrations.
13+
type migrationAborter interface {
14+
AbortMigration(ctx context.Context, id string) (bool, error)
15+
}
16+
17+
// newAbortMigrationCmd creates the abort-migration cobra command.
18+
func newAbortMigrationCmd(gh migrationAborter, log *logger.Logger) *cobra.Command {
19+
var migrationID string
20+
21+
cmd := &cobra.Command{
22+
Use: "abort-migration",
23+
Short: "Aborts a repository migration that is queued or in progress",
24+
Long: "Aborts a repository migration that is queued or in progress.",
25+
RunE: func(cmd *cobra.Command, args []string) error {
26+
if err := validateAbortMigrationID(migrationID); err != nil {
27+
return err
28+
}
29+
return runAbortMigration(cmd.Context(), gh, log, migrationID)
30+
},
31+
}
32+
33+
cmd.Flags().StringVar(&migrationID, "migration-id", "",
34+
"The ID of the migration to abort, starting with RM_. Organization migrations, where the ID starts with OM_, are not supported.")
35+
cmd.Flags().String("github-target-pat", "", "Personal access token for the target GitHub instance")
36+
cmd.Flags().String("target-api-url", "", "API URL for the target GitHub instance")
37+
38+
return cmd
39+
}
40+
41+
func validateAbortMigrationID(id string) error {
42+
if strings.TrimSpace(id) == "" {
43+
return cmdutil.NewUserError("--migration-id must be provided")
44+
}
45+
if !strings.HasPrefix(id, repoMigrationIDPrefix) {
46+
return cmdutil.NewUserErrorf(
47+
"Invalid migration ID: %s. Only repository migration IDs starting with RM_ are supported.", id)
48+
}
49+
return nil
50+
}
51+
52+
func runAbortMigration(ctx context.Context, gh migrationAborter, log *logger.Logger, migrationID string) error {
53+
success, err := gh.AbortMigration(ctx, migrationID)
54+
if err != nil {
55+
return err
56+
}
57+
if !success {
58+
log.Errorf("Failed to abort migration %s", migrationID)
59+
return nil
60+
}
61+
log.Info("Migration %s was canceled", migrationID)
62+
return nil
63+
}

0 commit comments

Comments
 (0)