Skip to content

Commit d4df46f

Browse files
committed
Phase 6 (partial): Port ADO API client + ado2gh migrate-repo command
ADO Client (pkg/ado/client.go): - Complete ADO REST API client with ~35 methods covering orgs, team projects, repos, pipelines, service connections, identity, and permissions - Three pagination patterns: continuation-token, top/skip, binary-search count - Cooperative Retry-After throttle with exponential backoff - PAT-based auth (base64-encoded), configurable base URL for ADO Server - 1559 lines implementation, 1431 lines tests, 74 lines models ado2gh migrate-repo (cmd/ado2gh/migrate_repo.go): - Full port of C# MigrateRepoCommandHandler with identical flag set - Token validation, org existence checks, permission verification - Queue-only mode support, wait-for-migration integration - Consumer-defined interfaces, two-constructor pattern (test/live) - 369 lines implementation, 414 lines tests (9 tests) Also: - Wired all remaining gei commands with live constructors (cmd/gei/wiring.go) - Fixed permissions error message quoting (backticks) in gei + ado2gh - Added gh-gei/, gh-ado2gh/, gh-bbs2gh/ to .gitignore
1 parent 2c6c40e commit d4df46f

10 files changed

Lines changed: 4069 additions & 337 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,9 @@ MigrationBackup/
367367
cmd/gei/gei
368368
cmd/ado2gh/ado2gh
369369
cmd/bbs2gh/bbs2gh
370+
gh-gei/
371+
gh-ado2gh/
372+
gh-bbs2gh/
370373

371374
# Go coverage reports
372375
coverage/

cmd/ado2gh/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ func newRootCmd() *cobra.Command {
5353
rootCmd.Version = version
5454

5555
// Add commands (will be implemented in phases)
56-
// rootCmd.AddCommand(newMigrateRepoCmd())
56+
rootCmd.AddCommand(newMigrateRepoCmdLive())
5757
// rootCmd.AddCommand(newGenerateScriptCmd())
5858
// rootCmd.AddCommand(newInventoryReportCmd())
5959
// rootCmd.AddCommand(newRewirePipelineCmd())

cmd/ado2gh/migrate_repo.go

Lines changed: 369 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,369 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"net/url"
7+
"strings"
8+
"time"
9+
10+
"github.com/github/gh-gei/internal/cmdutil"
11+
"github.com/github/gh-gei/pkg/env"
12+
"github.com/github/gh-gei/pkg/github"
13+
"github.com/github/gh-gei/pkg/logger"
14+
"github.com/github/gh-gei/pkg/migration"
15+
"github.com/spf13/cobra"
16+
)
17+
18+
// ---------------------------------------------------------------------------
19+
// Constants
20+
// ---------------------------------------------------------------------------
21+
22+
const (
23+
adoMigrationPollIntervalDefault = 60 * time.Second
24+
defaultAdoServerURL = "https://dev.azure.com"
25+
)
26+
27+
// ---------------------------------------------------------------------------
28+
// Consumer-defined interfaces
29+
// ---------------------------------------------------------------------------
30+
31+
// adoMigrateRepoGitHub defines the GitHub API methods needed by migrate-repo.
32+
type adoMigrateRepoGitHub interface {
33+
GetOrganizationId(ctx context.Context, org string) (string, error)
34+
CreateAdoMigrationSource(ctx context.Context, orgID, adoServerURL string) (string, error)
35+
StartMigration(ctx context.Context, migrationSourceID, sourceRepoURL, orgID, repo, sourceToken, targetToken string, opts ...github.StartMigrationOption) (string, error)
36+
GetMigration(ctx context.Context, id string) (*github.Migration, error)
37+
}
38+
39+
// adoMigrateRepoEnvProvider provides environment variable fallbacks.
40+
type adoMigrateRepoEnvProvider interface {
41+
TargetGitHubPAT() string
42+
ADOPAT() string
43+
}
44+
45+
// ---------------------------------------------------------------------------
46+
// Options (configurable for testing)
47+
// ---------------------------------------------------------------------------
48+
49+
type adoMigrateRepoOptions struct {
50+
pollInterval time.Duration
51+
}
52+
53+
// ---------------------------------------------------------------------------
54+
// Args struct
55+
// ---------------------------------------------------------------------------
56+
57+
type adoMigrateRepoArgs struct {
58+
adoOrg string
59+
adoTeamProject string
60+
adoRepo string
61+
githubOrg string
62+
githubRepo string
63+
adoServerURL string
64+
queueOnly bool
65+
targetRepoVisibility string
66+
targetAPIURL string
67+
adoPAT string
68+
githubPAT string
69+
}
70+
71+
// ---------------------------------------------------------------------------
72+
// Command constructor (testable)
73+
// ---------------------------------------------------------------------------
74+
75+
func newMigrateRepoCmd(
76+
gh adoMigrateRepoGitHub,
77+
envProv adoMigrateRepoEnvProvider,
78+
log *logger.Logger,
79+
opts adoMigrateRepoOptions,
80+
) *cobra.Command {
81+
var a adoMigrateRepoArgs
82+
83+
cmd := &cobra.Command{
84+
Use: "migrate-repo",
85+
Short: "Migrates an Azure DevOps repository to GitHub",
86+
Long: "Migrates a repository from Azure DevOps to GitHub.com using GitHub Enterprise Importer.",
87+
RunE: func(cmd *cobra.Command, _ []string) error {
88+
return runAdoMigrateRepo(cmd.Context(), gh, envProv, log, opts, a)
89+
},
90+
}
91+
92+
// Required flags
93+
cmd.Flags().StringVar(&a.adoOrg, "ado-org", "", "Azure DevOps organization name (REQUIRED)")
94+
cmd.Flags().StringVar(&a.adoTeamProject, "ado-team-project", "", "Azure DevOps team project name (REQUIRED)")
95+
cmd.Flags().StringVar(&a.adoRepo, "ado-repo", "", "Azure DevOps repository name (REQUIRED)")
96+
cmd.Flags().StringVar(&a.githubOrg, "github-org", "", "Target GitHub organization name (REQUIRED)")
97+
cmd.Flags().StringVar(&a.githubRepo, "github-repo", "", "Target GitHub repository name (REQUIRED)")
98+
99+
// Optional flags
100+
cmd.Flags().StringVar(&a.adoServerURL, "ado-server-url", "", "Azure DevOps Server URL (defaults to https://dev.azure.com)")
101+
cmd.Flags().BoolVar(&a.queueOnly, "queue-only", false, "Queue the migration without waiting for completion")
102+
cmd.Flags().StringVar(&a.targetRepoVisibility, "target-repo-visibility", "", "Target repository visibility (public, private, internal)")
103+
cmd.Flags().StringVar(&a.targetAPIURL, "target-api-url", "", "API URL for the target GitHub instance")
104+
cmd.Flags().StringVar(&a.adoPAT, "ado-pat", "", "Azure DevOps personal access token (falls back to ADO_PAT env)")
105+
cmd.Flags().StringVar(&a.githubPAT, "github-pat", "", "GitHub personal access token (falls back to GH_PAT env)")
106+
107+
// Hidden flags
108+
_ = cmd.Flags().MarkHidden("ado-server-url")
109+
110+
return cmd
111+
}
112+
113+
// ---------------------------------------------------------------------------
114+
// Production command constructor
115+
// ---------------------------------------------------------------------------
116+
117+
func newMigrateRepoCmdLive() *cobra.Command {
118+
var a adoMigrateRepoArgs
119+
120+
cmd := &cobra.Command{
121+
Use: "migrate-repo",
122+
Short: "Migrates an Azure DevOps repository to GitHub",
123+
Long: "Migrates a repository from Azure DevOps to GitHub.com using GitHub Enterprise Importer.",
124+
RunE: func(cmd *cobra.Command, _ []string) error {
125+
log := getLogger(cmd)
126+
envProv := &adoEnvProviderAdapter{prov: env.New()}
127+
128+
// Resolve tokens for client construction
129+
githubPAT := a.githubPAT
130+
if githubPAT == "" {
131+
githubPAT = envProv.TargetGitHubPAT()
132+
}
133+
134+
apiURL := a.targetAPIURL
135+
if apiURL == "" {
136+
apiURL = "https://api.github.com"
137+
}
138+
139+
gh := github.NewClient(githubPAT,
140+
github.WithAPIURL(apiURL),
141+
github.WithLogger(log),
142+
github.WithVersion(version),
143+
)
144+
145+
opts := adoMigrateRepoOptions{
146+
pollInterval: adoMigrationPollIntervalDefault,
147+
}
148+
149+
return runAdoMigrateRepo(cmd.Context(), gh, envProv, log, opts, a)
150+
},
151+
}
152+
153+
// Required flags
154+
cmd.Flags().StringVar(&a.adoOrg, "ado-org", "", "Azure DevOps organization name (REQUIRED)")
155+
cmd.Flags().StringVar(&a.adoTeamProject, "ado-team-project", "", "Azure DevOps team project name (REQUIRED)")
156+
cmd.Flags().StringVar(&a.adoRepo, "ado-repo", "", "Azure DevOps repository name (REQUIRED)")
157+
cmd.Flags().StringVar(&a.githubOrg, "github-org", "", "Target GitHub organization name (REQUIRED)")
158+
cmd.Flags().StringVar(&a.githubRepo, "github-repo", "", "Target GitHub repository name (REQUIRED)")
159+
160+
// Optional flags
161+
cmd.Flags().StringVar(&a.adoServerURL, "ado-server-url", "", "Azure DevOps Server URL (defaults to https://dev.azure.com)")
162+
cmd.Flags().BoolVar(&a.queueOnly, "queue-only", false, "Queue the migration without waiting for completion")
163+
cmd.Flags().StringVar(&a.targetRepoVisibility, "target-repo-visibility", "", "Target repository visibility (public, private, internal)")
164+
cmd.Flags().StringVar(&a.targetAPIURL, "target-api-url", "", "API URL for the target GitHub instance")
165+
cmd.Flags().StringVar(&a.adoPAT, "ado-pat", "", "Azure DevOps personal access token (falls back to ADO_PAT env)")
166+
cmd.Flags().StringVar(&a.githubPAT, "github-pat", "", "GitHub personal access token (falls back to GH_PAT env)")
167+
168+
// Hidden flags
169+
_ = cmd.Flags().MarkHidden("ado-server-url")
170+
171+
return cmd
172+
}
173+
174+
// adoEnvProviderAdapter wraps env.Provider to satisfy adoMigrateRepoEnvProvider.
175+
type adoEnvProviderAdapter struct {
176+
prov *env.Provider
177+
}
178+
179+
func (a *adoEnvProviderAdapter) TargetGitHubPAT() string { return a.prov.TargetGitHubPAT() }
180+
func (a *adoEnvProviderAdapter) ADOPAT() string { return a.prov.ADOPAT() }
181+
182+
// ---------------------------------------------------------------------------
183+
// Validation
184+
// ---------------------------------------------------------------------------
185+
186+
func validateAdoMigrateRepoArgs(a *adoMigrateRepoArgs) error {
187+
if err := cmdutil.ValidateRequired(a.adoOrg, "--ado-org"); err != nil {
188+
return err
189+
}
190+
if err := cmdutil.ValidateRequired(a.adoTeamProject, "--ado-team-project"); err != nil {
191+
return err
192+
}
193+
if err := cmdutil.ValidateRequired(a.adoRepo, "--ado-repo"); err != nil {
194+
return err
195+
}
196+
if err := cmdutil.ValidateRequired(a.githubOrg, "--github-org"); err != nil {
197+
return err
198+
}
199+
if err := cmdutil.ValidateRequired(a.githubRepo, "--github-repo"); err != nil {
200+
return err
201+
}
202+
203+
// URL validation
204+
if err := cmdutil.ValidateNoURL(a.githubOrg, "--github-org"); err != nil {
205+
return err
206+
}
207+
if err := cmdutil.ValidateNoURL(a.githubRepo, "--github-repo"); err != nil {
208+
return err
209+
}
210+
211+
// Target repo visibility
212+
if err := cmdutil.ValidateOneOf(a.targetRepoVisibility, "--target-repo-visibility", "public", "private", "internal"); err != nil {
213+
return err
214+
}
215+
216+
return nil
217+
}
218+
219+
// ---------------------------------------------------------------------------
220+
// Runner
221+
// ---------------------------------------------------------------------------
222+
223+
func runAdoMigrateRepo(
224+
ctx context.Context,
225+
gh adoMigrateRepoGitHub,
226+
envProv adoMigrateRepoEnvProvider,
227+
log *logger.Logger,
228+
opts adoMigrateRepoOptions,
229+
a adoMigrateRepoArgs,
230+
) error {
231+
if err := validateAdoMigrateRepoArgs(&a); err != nil {
232+
return err
233+
}
234+
235+
log.Info("Migrating Repo...")
236+
237+
// Resolve tokens from flags or environment
238+
if a.githubPAT == "" {
239+
a.githubPAT = envProv.TargetGitHubPAT()
240+
}
241+
if a.adoPAT == "" {
242+
a.adoPAT = envProv.ADOPAT()
243+
}
244+
245+
// Build ADO repo URL
246+
adoRepoURL := getAdoRepoURL(a.adoOrg, a.adoTeamProject, a.adoRepo, a.adoServerURL)
247+
248+
// Get org ID
249+
githubOrgID, err := gh.GetOrganizationId(ctx, a.githubOrg)
250+
if err != nil {
251+
return err
252+
}
253+
254+
// Create migration source
255+
migrationSourceID, err := gh.CreateAdoMigrationSource(ctx, githubOrgID, a.adoServerURL)
256+
if err != nil {
257+
if strings.Contains(err.Error(), "not have the correct permissions to execute") {
258+
msg := fmt.Sprintf("%s%s", err.Error(), adoInsufficientPermissionsMessage(a.githubOrg))
259+
return cmdutil.NewUserError(msg)
260+
}
261+
return err
262+
}
263+
264+
// Build migration options
265+
var migOpts []github.StartMigrationOption
266+
if a.targetRepoVisibility != "" {
267+
migOpts = append(migOpts, github.WithTargetRepoVisibility(a.targetRepoVisibility))
268+
}
269+
270+
// Start migration
271+
migrationID, err := gh.StartMigration(ctx, migrationSourceID, adoRepoURL, githubOrgID, a.githubRepo, a.adoPAT, a.githubPAT, migOpts...)
272+
if err != nil {
273+
if err.Error() == fmt.Sprintf("A repository called %s/%s already exists", a.githubOrg, a.githubRepo) {
274+
log.Warning("The Org '%s' already contains a repository with the name '%s'. No operation will be performed", a.githubOrg, a.githubRepo)
275+
return nil
276+
}
277+
return err
278+
}
279+
280+
// Queue-only mode
281+
if a.queueOnly {
282+
log.Info("A repository migration (ID: %s) was successfully queued.", migrationID)
283+
return nil
284+
}
285+
286+
return adoWaitForMigration(ctx, gh, log, opts.pollInterval, migrationID, a.githubOrg, a.githubRepo)
287+
}
288+
289+
func adoWaitForMigration(
290+
ctx context.Context,
291+
gh adoMigrateRepoGitHub,
292+
log *logger.Logger,
293+
pollInterval time.Duration,
294+
migrationID, githubOrg, githubRepo string,
295+
) error {
296+
m, err := gh.GetMigration(ctx, migrationID)
297+
if err != nil {
298+
return err
299+
}
300+
301+
for migration.IsRepoPending(m.State) {
302+
log.Info("Migration in progress (ID: %s). State: %s. Waiting %s...", migrationID, m.State, adoFormatPollInterval(pollInterval))
303+
304+
select {
305+
case <-ctx.Done():
306+
return ctx.Err()
307+
case <-time.After(pollInterval):
308+
}
309+
310+
m, err = gh.GetMigration(ctx, migrationID)
311+
if err != nil {
312+
return err
313+
}
314+
}
315+
316+
if migration.IsRepoFailed(m.State) {
317+
log.Errorf("Migration Failed. Migration ID: %s", migrationID)
318+
adoLogWarningsCount(log, m.WarningsCount)
319+
log.Info("Migration log available at %s or by running `gh ado2gh download-logs --github-org %s --github-repo %s`", m.MigrationLogURL, githubOrg, githubRepo)
320+
return cmdutil.NewUserError(m.FailureReason)
321+
}
322+
323+
log.Success("Migration completed (ID: %s)! State: %s", migrationID, m.State)
324+
adoLogWarningsCount(log, m.WarningsCount)
325+
log.Info("Migration log available at %s or by running `gh ado2gh download-logs --github-org %s --github-repo %s`", m.MigrationLogURL, githubOrg, githubRepo)
326+
327+
return nil
328+
}
329+
330+
// ---------------------------------------------------------------------------
331+
// Helpers
332+
// ---------------------------------------------------------------------------
333+
334+
func getAdoRepoURL(org, project, repo, serverURL string) string {
335+
if strings.TrimSpace(serverURL) != "" {
336+
serverURL = strings.TrimRight(serverURL, "/")
337+
} else {
338+
serverURL = defaultAdoServerURL
339+
}
340+
return fmt.Sprintf("%s/%s/%s/_git/%s",
341+
serverURL,
342+
url.PathEscape(org),
343+
url.PathEscape(project),
344+
url.PathEscape(repo),
345+
)
346+
}
347+
348+
func adoInsufficientPermissionsMessage(org string) string {
349+
return fmt.Sprintf(". Please check that:\n (a) you are a member of the `%s` organization,\n (b) you are an organization owner or you have been granted the migrator role and\n (c) your personal access token has the correct scopes.\nFor more information, see https://docs.github.com/en/migrations/using-github-enterprise-importer/preparing-to-migrate-with-github-enterprise-importer/managing-access-for-github-enterprise-importer.", org)
350+
}
351+
352+
func adoLogWarningsCount(log *logger.Logger, count int) {
353+
switch count {
354+
case 0:
355+
// no output
356+
case 1:
357+
log.Warning("1 warning encountered during this migration")
358+
default:
359+
log.Warning("%d warnings encountered during this migration", count)
360+
}
361+
}
362+
363+
func adoFormatPollInterval(d time.Duration) string {
364+
secs := int(d.Seconds())
365+
if secs == 0 {
366+
return "0 seconds"
367+
}
368+
return fmt.Sprintf("%d seconds", secs)
369+
}

0 commit comments

Comments
 (0)