From 3b59ba0e4f69d6ab2d02379baebdb96f5a191c58 Mon Sep 17 00:00:00 2001 From: Chris Rose Date: Wed, 1 Apr 2026 10:01:10 -0700 Subject: [PATCH 1/4] Phase 8: CI/CD workflow updates for Go binaries Update GitHub Actions workflows to build, test, lint, and publish Go binaries alongside the existing pipeline. CI.yml changes: - Add "go" to the build matrix; gate C# steps with language condition - Go build steps: setup-go, just go-build, just go-test, golangci-lint - build-for-e2e-test: replace dotnet publish with Go cross-compilation (just go-publish-linux/windows/macos) producing platform binaries - e2e-test: update binary copy steps to reference Go binary names; keep Setup .NET since integration tests are C# projects that invoke binaries - publish: replace dotnet/publish.ps1 with just go-publish-all; release 18 flat binaries (3 CLIs x 6 platform/arch combos) instead of zips Other workflow changes: - codeql-config.yml: remove disable-default-queries so Go gets CodeQL default query suite - dependabot.yml: add gomod ecosystem for Go dependency updates - copilot-setup-steps.yml: add Go setup and go mod download Justfile fixes: - go-publish-linux: add mkdir -p for dist/linux-arm64 directory - go-publish-macos: add arm64 builds (darwin-arm64) for all 3 CLIs --- .github/codeql/codeql-config.yml | 1 - .github/dependabot.yml | 4 ++ .github/workflows/CI.yml | 71 +++++++++++++++++------ .github/workflows/copilot-setup-steps.yml | 8 +++ justfile | 21 +++++-- 5 files changed, 82 insertions(+), 23 deletions(-) diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml index fd4bdcc27..d9203f69d 100644 --- a/.github/codeql/codeql-config.yml +++ b/.github/codeql/codeql-config.yml @@ -1,5 +1,4 @@ name: "CodeQL code scanning custom configuration" -disable-default-queries: true queries: - name: Use the custom query suite file from this repo uses: ./.github/codeql/csharp-custom-queries.qls \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml index ee4a6e7a5..1a07f15d2 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -10,6 +10,10 @@ updates: directory: "/" # Location of package manifests schedule: interval: "weekly" + - package-ecosystem: "gomod" + directory: "/" + schedule: + interval: "weekly" - package-ecosystem: "github-actions" # See documentation for possible values directory: "/" # Location of package manifests schedule: diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 89d09efb2..d38086e13 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, go, actions] runs-on: ${{ matrix.runner-os }} @@ -42,21 +42,27 @@ jobs: queries: +security-and-quality config-file: ./.github/codeql/codeql-config.yml + # --- C# steps --- - name: Setup .NET + if: matrix.language == 'csharp' uses: actions/setup-dotnet@v5 with: global-json-file: global.json - name: dotnet format + if: matrix.language == 'csharp' run: just format-check - name: Restore dependencies + if: matrix.language == 'csharp' run: just restore - name: Build + if: matrix.language == 'csharp' run: just build - name: Unit Test + if: matrix.language == 'csharp' run: just test-coverage - name: Copy Coverage To Predictable Location @@ -89,6 +95,27 @@ jobs: name: Code Coverage Report path: code-coverage-results.md + # --- Go steps --- + - name: Setup Go + if: matrix.language == 'go' + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Go Build + if: matrix.language == 'go' + run: just go-build + + - name: Go Test + if: matrix.language == 'go' + run: just go-test + + - name: Go Lint + if: matrix.language == 'go' + uses: golangci/golangci-lint-action@v8 + with: + version: v2.11.3 + - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v4 if: matrix.runner-os == 'ubuntu-latest' @@ -123,22 +150,22 @@ jobs: # e33e0265a09d6d736e2ee1e0eb685ef1de4669ff is tag v3, pinned to avoid supply chain attacks - uses: extractions/setup-just@f8a3cce218d9f83db3a2ecd90e41ac3de6cdfd9b - - name: Setup .NET - uses: actions/setup-dotnet@v5 + - name: Setup Go + uses: actions/setup-go@v5 with: - global-json-file: global.json + go-version-file: go.mod - name: Build Artifacts (Linux) if: matrix.target-os == 'ubuntu-latest' - run: just publish-linux + run: just go-publish-linux - name: Build Artifacts (Windows) if: matrix.target-os == 'windows-latest' - run: just publish-windows + run: just go-publish-windows - name: Build Artifacts (MacOS) if: matrix.target-os == 'macos-latest' - run: just publish-macos + run: just go-publish-macos - name: Upload Binaries uses: actions/upload-artifact@v6 @@ -325,14 +352,16 @@ jobs: exit 1 } - - name: Setup .NET - uses: actions/setup-dotnet@v5 + - name: Setup Go + uses: actions/setup-go@v5 with: - global-json-file: global.json + go-version-file: go.mod + + # e33e0265a09d6d736e2ee1e0eb685ef1de4669ff is tag v3, pinned to avoid supply chain attacks + - uses: extractions/setup-just@f8a3cce218d9f83db3a2ecd90e41ac3de6cdfd9b - name: Build Artifacts - run: ./publish.ps1 - shell: pwsh + run: just go-publish-all env: CLI_VERSION: ${{ github.ref }} @@ -342,18 +371,24 @@ jobs: with: body_path: ./RELEASENOTES.md files: | - ./dist/ado2gh.*.win-x64.zip - ./dist/ado2gh.*.win-x86.zip - ./dist/ado2gh.*.linux-x64.tar.gz - ./dist/ado2gh.*.linux-arm64.tar.gz - ./dist/ado2gh.*.osx-x64.tar.gz - ./dist/ado2gh.*.osx-arm64.tar.gz ./dist/win-x64/gei-windows-amd64.exe ./dist/win-x86/gei-windows-386.exe ./dist/linux-x64/gei-linux-amd64 ./dist/linux-arm64/gei-linux-arm64 ./dist/osx-x64/gei-darwin-amd64 ./dist/osx-arm64/gei-darwin-arm64 + ./dist/win-x64/ado2gh-windows-amd64.exe + ./dist/win-x86/ado2gh-windows-386.exe + ./dist/linux-x64/ado2gh-linux-amd64 + ./dist/linux-arm64/ado2gh-linux-arm64 + ./dist/osx-x64/ado2gh-darwin-amd64 + ./dist/osx-arm64/ado2gh-darwin-arm64 + ./dist/win-x64/bbs2gh-windows-amd64.exe + ./dist/win-x86/bbs2gh-windows-386.exe + ./dist/linux-x64/bbs2gh-linux-amd64 + ./dist/linux-arm64/bbs2gh-linux-arm64 + ./dist/osx-x64/bbs2gh-darwin-amd64 + ./dist/osx-arm64/bbs2gh-darwin-arm64 - name: Create gh-ado2gh Release # a06a81a03ee405af7f2048a818ed3f03bbf83c7b is tag v2, pinned to avoid supply chain attacks diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index ddfe68ef8..76f6dccef 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -27,3 +27,11 @@ jobs: - name: Restore dependencies run: dotnet restore src/OctoshiftCLI.sln + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Download Go dependencies + run: go mod download diff --git a/justfile b/justfile index 5b162e67c..f09406b84 100644 --- a/justfile +++ b/justfile @@ -167,7 +167,7 @@ go-lint: # Build Go binaries for Linux go-publish-linux: - mkdir -p dist/linux-x64 + mkdir -p dist/linux-x64 dist/linux-arm64 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 @@ -187,10 +187,13 @@ go-publish-windows: # Build Go binaries for macOS go-publish-macos: - mkdir -p dist/osx-x64 + mkdir -p dist/osx-x64 dist/osx-arm64 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 + GOOS=darwin GOARCH=arm64 go build -o dist/osx-arm64/gei-darwin-arm64 ./cmd/gei + GOOS=darwin GOARCH=arm64 go build -o dist/osx-arm64/ado2gh-darwin-arm64 ./cmd/ado2gh + GOOS=darwin GOARCH=arm64 go build -o dist/osx-arm64/bbs2gh-darwin-arm64 ./cmd/bbs2gh # Build Go binaries for all platforms go-publish-all: go-publish-linux go-publish-windows go-publish-macos @@ -199,12 +202,22 @@ go-publish-all: go-publish-linux go-publish-windows go-publish-macos go-install-extensions-macos: go-publish-macos #!/usr/bin/env bash set -euo pipefail + arch=$(uname -m) + if [ "$arch" = "arm64" ]; then + dist_dir="osx-arm64" + suffix="darwin-arm64" + else + dist_dir="osx-x64" + suffix="darwin-amd64" + fi for cli in gei ado2gh bbs2gh; do dir="gh-${cli}" mkdir -p "$dir" - cp "./dist/osx-x64/${cli}-darwin-amd64" "./${dir}/gh-${cli}" + cp "./dist/${dist_dir}/${cli}-${suffix}" "./${dir}/gh-${cli}" chmod +x "./${dir}/gh-${cli}" - cd "$dir" && gh extension install . --force && cd .. + pushd "$dir" > /dev/null + gh extension install . --force || true + popd > /dev/null done echo "Go extensions installed successfully!" From 7380ad427ef627c7d1048098ddf649cd1cc5bade Mon Sep 17 00:00:00 2001 From: Chris Rose Date: Wed, 1 Apr 2026 11:24:40 -0700 Subject: [PATCH 2/4] Phase 10: Remove deprecated pkg/http and pkg/app packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These packages were scaffolded early in the port but never used by any consumer. All HTTP needs are served by net/http directly (ADO/BBS clients), google/go-github (GitHub REST), and a thin GraphQL client. Wiring is explicit in each binary's main.go — no app container needed. Removed: - pkg/http/client.go, pkg/http/client_test.go - pkg/app/app.go, pkg/app/app_test.go Verified: build, all tests, lint — no regressions. --- pkg/app/app.go | 78 ------------- pkg/app/app_test.go | 50 -------- pkg/http/client.go | 249 ---------------------------------------- pkg/http/client_test.go | 214 ---------------------------------- 4 files changed, 591 deletions(-) delete mode 100644 pkg/app/app.go delete mode 100644 pkg/app/app_test.go delete mode 100644 pkg/http/client.go delete mode 100644 pkg/http/client_test.go diff --git a/pkg/app/app.go b/pkg/app/app.go deleted file mode 100644 index 58e2af7a4..000000000 --- a/pkg/app/app.go +++ /dev/null @@ -1,78 +0,0 @@ -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 deleted file mode 100644 index 07e7f817b..000000000 --- a/pkg/app/app_test.go +++ /dev/null @@ -1,50 +0,0 @@ -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/http/client.go b/pkg/http/client.go deleted file mode 100644 index 5eb23aaf1..000000000 --- a/pkg/http/client.go +++ /dev/null @@ -1,249 +0,0 @@ -package http - -import ( - "bytes" - "context" - "crypto/tls" - "encoding/json" - "fmt" - "io" - "net/http" - "time" - - "github.com/github/gh-gei/pkg/logger" - "github.com/github/gh-gei/pkg/retry" -) - -// Client is a shared HTTP client with retry logic -type Client struct { - httpClient *http.Client - retryPolicy *retry.Policy - logger *logger.Logger -} - -// Config contains configuration for the HTTP client -type Config struct { - Timeout time.Duration - RetryAttempts int - NoSSLVerify bool -} - -// DefaultConfig returns a Config with sensible defaults -func DefaultConfig() Config { - return Config{ - Timeout: 30 * time.Second, - RetryAttempts: 3, - NoSSLVerify: false, - } -} - -// NewClient creates a new HTTP client with the given configuration -func NewClient(cfg Config, log *logger.Logger) *Client { - transport := &http.Transport{ - TLSClientConfig: &tls.Config{ - InsecureSkipVerify: cfg.NoSSLVerify, //nolint:gosec // User-configurable for GHES with self-signed certs - }, - } - - httpClient := &http.Client{ - Timeout: cfg.Timeout, - Transport: transport, - } - - var attempts uint - if cfg.RetryAttempts > 0 { - attempts = uint(cfg.RetryAttempts) //nolint:gosec // RetryAttempts is validated positive - } - retryPolicy := retry.New( - retry.WithMaxAttempts(attempts), - retry.WithDelay(1*time.Second), - retry.WithMaxDelay(30*time.Second), - ) - - return &Client{ - httpClient: httpClient, - retryPolicy: retryPolicy, - logger: log, - } -} - -// Get performs an HTTP GET request with retry logic -func (c *Client) Get(ctx context.Context, url string, headers map[string]string) ([]byte, error) { - var responseBody []byte - - err := c.retryPolicy.Execute(ctx, func() error { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return fmt.Errorf("failed to create request: %w", err) - } - - for key, value := range headers { - req.Header.Set(key, value) - } - - c.logger.Debug("HTTP GET: %s", url) - - resp, err := c.httpClient.Do(req) - if err != nil { - return fmt.Errorf("request failed: %w", err) - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return fmt.Errorf("failed to read response body: %w", err) - } - - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body)) - } - - responseBody = body - return nil - }) - if err != nil { - return nil, err - } - - return responseBody, nil -} - -// Post performs an HTTP POST request with retry logic -func (c *Client) Post(ctx context.Context, url string, body []byte, headers map[string]string) ([]byte, error) { - var responseBody []byte - - err := c.retryPolicy.Execute(ctx, func() error { - req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) - if err != nil { - return fmt.Errorf("failed to create request: %w", err) - } - - for key, value := range headers { - req.Header.Set(key, value) - } - - // Set default Content-Type if not provided - if req.Header.Get("Content-Type") == "" { - req.Header.Set("Content-Type", "application/json") - } - - c.logger.Debug("HTTP POST: %s", url) - - resp, err := c.httpClient.Do(req) - if err != nil { - return fmt.Errorf("request failed: %w", err) - } - defer resp.Body.Close() - - respBody, err := io.ReadAll(resp.Body) - if err != nil { - return fmt.Errorf("failed to read response body: %w", err) - } - - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody)) - } - - responseBody = respBody - return nil - }) - if err != nil { - return nil, err - } - - return responseBody, nil -} - -// Put performs an HTTP PUT request with retry logic -func (c *Client) Put(ctx context.Context, url string, body []byte, headers map[string]string) ([]byte, error) { - var responseBody []byte - - err := c.retryPolicy.Execute(ctx, func() error { - req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, bytes.NewReader(body)) - if err != nil { - return fmt.Errorf("failed to create request: %w", err) - } - - for key, value := range headers { - req.Header.Set(key, value) - } - - if req.Header.Get("Content-Type") == "" { - req.Header.Set("Content-Type", "application/json") - } - - c.logger.Debug("HTTP PUT: %s", url) - - resp, err := c.httpClient.Do(req) - if err != nil { - return fmt.Errorf("request failed: %w", err) - } - defer resp.Body.Close() - - respBody, err := io.ReadAll(resp.Body) - if err != nil { - return fmt.Errorf("failed to read response body: %w", err) - } - - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody)) - } - - responseBody = respBody - return nil - }) - if err != nil { - return nil, err - } - - return responseBody, nil -} - -// Delete performs an HTTP DELETE request with retry logic -func (c *Client) Delete(ctx context.Context, url string, headers map[string]string) error { - return c.retryPolicy.Execute(ctx, func() error { - req, err := http.NewRequestWithContext(ctx, http.MethodDelete, url, nil) - if err != nil { - return fmt.Errorf("failed to create request: %w", err) - } - - for key, value := range headers { - req.Header.Set(key, value) - } - - c.logger.Debug("HTTP DELETE: %s", url) - - resp, err := c.httpClient.Do(req) - if err != nil { - return fmt.Errorf("request failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - body, _ := io.ReadAll(resp.Body) - return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body)) - } - - return nil - }) -} - -// PostJSON is a convenience method for posting JSON data -func (c *Client) PostJSON(ctx context.Context, url string, payload interface{}, headers map[string]string) ([]byte, error) { - jsonData, err := json.Marshal(payload) - if err != nil { - return nil, fmt.Errorf("failed to marshal JSON: %w", err) - } - - return c.Post(ctx, url, jsonData, headers) -} - -// PutJSON is a convenience method for putting JSON data -func (c *Client) PutJSON(ctx context.Context, url string, payload interface{}, headers map[string]string) ([]byte, error) { - jsonData, err := json.Marshal(payload) - if err != nil { - return nil, fmt.Errorf("failed to marshal JSON: %w", err) - } - - return c.Put(ctx, url, jsonData, headers) -} diff --git a/pkg/http/client_test.go b/pkg/http/client_test.go deleted file mode 100644 index 53d3fcd08..000000000 --- a/pkg/http/client_test.go +++ /dev/null @@ -1,214 +0,0 @@ -package http - -import ( - "context" - "net/http" - "net/http/httptest" - "testing" - "time" - - "github.com/github/gh-gei/pkg/logger" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestNewClient(t *testing.T) { - log := logger.New(false) - cfg := DefaultConfig() - - client := NewClient(cfg, log) - - assert.NotNil(t, client) - assert.NotNil(t, client.httpClient) - assert.NotNil(t, client.retryPolicy) - assert.NotNil(t, client.logger) -} - -func TestClient_Get(t *testing.T) { - log := logger.New(false) - - t.Run("successful GET request", func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, http.MethodGet, r.Method) - assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization")) - w.WriteHeader(http.StatusOK) - w.Write([]byte(`{"message":"success"}`)) - })) - defer server.Close() - - client := NewClient(DefaultConfig(), log) - ctx := context.Background() - - headers := map[string]string{ - "Authorization": "Bearer test-token", - } - - body, err := client.Get(ctx, server.URL, headers) - - require.NoError(t, err) - assert.Equal(t, `{"message":"success"}`, string(body)) - }) - - t.Run("GET request with retry on 500", func(t *testing.T) { - attempts := 0 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - attempts++ - if attempts < 2 { - w.WriteHeader(http.StatusInternalServerError) - return - } - w.WriteHeader(http.StatusOK) - w.Write([]byte("success")) - })) - defer server.Close() - - cfg := DefaultConfig() - cfg.RetryAttempts = 3 - client := NewClient(cfg, log) - ctx := context.Background() - - body, err := client.Get(ctx, server.URL, nil) - - require.NoError(t, err) - assert.Equal(t, "success", string(body)) - assert.Equal(t, 2, attempts) - }) - - t.Run("GET request fails after max retries", func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("server error")) - })) - defer server.Close() - - cfg := DefaultConfig() - cfg.RetryAttempts = 2 - client := NewClient(cfg, log) - ctx := context.Background() - - _, err := client.Get(ctx, server.URL, nil) - - require.Error(t, err) - assert.Contains(t, err.Error(), "HTTP 500") - }) -} - -func TestClient_Post(t *testing.T) { - log := logger.New(false) - - t.Run("successful POST request", func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, http.MethodPost, r.Method) - assert.Equal(t, "application/json", r.Header.Get("Content-Type")) - w.WriteHeader(http.StatusCreated) - w.Write([]byte(`{"id":"123"}`)) - })) - defer server.Close() - - client := NewClient(DefaultConfig(), log) - ctx := context.Background() - - body, err := client.Post(ctx, server.URL, []byte(`{"name":"test"}`), nil) - - require.NoError(t, err) - assert.Equal(t, `{"id":"123"}`, string(body)) - }) -} - -func TestClient_PostJSON(t *testing.T) { - log := logger.New(false) - - t.Run("successful POST JSON request", func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, http.MethodPost, r.Method) - assert.Equal(t, "application/json", r.Header.Get("Content-Type")) - w.WriteHeader(http.StatusOK) - w.Write([]byte(`{"result":"ok"}`)) - })) - defer server.Close() - - client := NewClient(DefaultConfig(), log) - ctx := context.Background() - - payload := map[string]string{"name": "test"} - body, err := client.PostJSON(ctx, server.URL, payload, nil) - - require.NoError(t, err) - assert.Equal(t, `{"result":"ok"}`, string(body)) - }) -} - -func TestClient_Put(t *testing.T) { - log := logger.New(false) - - t.Run("successful PUT request", func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, http.MethodPut, r.Method) - w.WriteHeader(http.StatusOK) - w.Write([]byte(`{"updated":true}`)) - })) - defer server.Close() - - client := NewClient(DefaultConfig(), log) - ctx := context.Background() - - body, err := client.Put(ctx, server.URL, []byte(`{"field":"value"}`), nil) - - require.NoError(t, err) - assert.Equal(t, `{"updated":true}`, string(body)) - }) -} - -func TestClient_Delete(t *testing.T) { - log := logger.New(false) - - t.Run("successful DELETE request", func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, http.MethodDelete, r.Method) - w.WriteHeader(http.StatusNoContent) - })) - defer server.Close() - - client := NewClient(DefaultConfig(), log) - ctx := context.Background() - - err := client.Delete(ctx, server.URL, nil) - - require.NoError(t, err) - }) -} - -func TestClient_NoSSLVerify(t *testing.T) { - log := logger.New(false) - - cfg := DefaultConfig() - cfg.NoSSLVerify = true - - client := NewClient(cfg, log) - - assert.NotNil(t, client) - // Cannot easily test SSL verification without setting up an HTTPS server - // But we verify the client is created successfully -} - -func TestClient_Timeout(t *testing.T) { - log := logger.New(false) - - t.Run("request times out", func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - time.Sleep(2 * time.Second) - w.WriteHeader(http.StatusOK) - })) - defer server.Close() - - cfg := DefaultConfig() - cfg.Timeout = 100 * time.Millisecond - cfg.RetryAttempts = 1 - client := NewClient(cfg, log) - ctx := context.Background() - - _, err := client.Get(ctx, server.URL, nil) - - require.Error(t, err) - }) -} From 3a29708d628fa145bbcb3db882551b1a44b1f633 Mon Sep 17 00:00:00 2001 From: Chris Rose Date: Wed, 22 Apr 2026 08:54:22 -0700 Subject: [PATCH 3/4] Update copilot-instructions.md for Go port phase 8 --- .github/copilot-instructions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 048640c0a..da95b935a 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -44,7 +44,7 @@ This is a C# based repository that produces several CLIs that are used by custom ## Go Port Sync Requirements -**Current state:** All three CLIs (`gei`, `ado2gh`, `bbs2gh`) are fully ported to Go. Every command has behavioral parity with the C# version. Any C# behavioral change must be reflected in the Go port. +**Current state:** All three CLIs (`gei`, `ado2gh`, `bbs2gh`) are fully ported to Go. Every command has behavioral parity with the C# version. Any C# behavioral change must be reflected in the Go port. **CI runs e2e tests against the Go binaries** — the Go port is the primary build artifact. **When making C# changes, you MUST make the corresponding Go change:** From 6cb121347a9f318e3e3828302e65a142a7e1186a Mon Sep 17 00:00:00 2001 From: Chris Rose Date: Wed, 1 Apr 2026 14:07:42 -0700 Subject: [PATCH 4/4] Phase 9: Wire live constructors, fix auth/parsing/validation bugs for CI - Wire ado2gh generate-script live constructor with real ADO client + inspector - Wire gei migrate-repo storage backends (Azure, AWS, GitHub-owned) - Wire bbs2gh migrate-repo GitHub-owned storage - Fix tokenRoundTripper auth: use "Bearer" instead of "token" (gei + bbs2gh) - Fix ado2gh + bbs2gh main.go: print errors before os.Exit(1) instead of silencing - Add Content-Type: application/octet-stream header to ghowned single upload - Include response body in ghowned client error messages for better debugging - Fix golangci-lint v2 issues (5 fixes across alerts and github packages) - Fix ADO repo model: remove ,string JSON tags from Size/IsDisabled (ADO API returns numbers/booleans, not strings) - Fix BBS hasAWSSubOptions: only check CLI flags, not env vars (matches C# behavior; env vars like AWS_ACCESS_KEY_ID should not trigger bucket-name validation) - Fix ghowned parseURIResponse: use map[string]interface{} to handle mixed-type API responses (size is a number, not a string) --- cmd/ado2gh/generate_script.go | 57 +++++++++++++++- cmd/ado2gh/main.go | 16 ++++- cmd/ado2gh/wiring.go | 4 +- cmd/bbs2gh/main.go | 16 ++++- cmd/bbs2gh/migrate_repo.go | 79 +++++++++++++++++---- cmd/bbs2gh/migrate_repo_test.go | 83 ++++++++++++++++++++++ cmd/bbs2gh/wiring.go | 4 +- cmd/gei/migrate_repo.go | 102 ++++++++++++++++++++++++++-- pkg/ado/client.go | 55 ++++++++------- pkg/ado/client_test.go | 10 +-- pkg/ado/models.go | 4 +- pkg/ado/pipeline_trigger_service.go | 4 +- pkg/github/client.go | 45 +++++++----- pkg/github/client_test.go | 30 ++++++++ pkg/storage/ghowned/client.go | 21 ++++-- pkg/storage/ghowned/client_test.go | 12 ++-- 16 files changed, 450 insertions(+), 92 deletions(-) diff --git a/cmd/ado2gh/generate_script.go b/cmd/ado2gh/generate_script.go index 40fa1af95..0ec9ce008 100644 --- a/cmd/ado2gh/generate_script.go +++ b/cmd/ado2gh/generate_script.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/github/gh-gei/pkg/ado" + "github.com/github/gh-gei/pkg/env" "github.com/github/gh-gei/pkg/logger" "github.com/github/gh-gei/pkg/scriptgen" "github.com/spf13/cobra" @@ -43,6 +44,7 @@ type generateScriptArgs struct { githubOrg string adoOrg string adoTeamProject string + adoPAT string output string sequential bool adoServerURL string @@ -132,11 +134,60 @@ func newGenerateScriptCmd( // --------------------------------------------------------------------------- func newGenerateScriptCmdLive() *cobra.Command { - // TODO: wire up real ADO client and inspector - return &cobra.Command{ + var a generateScriptArgs + + cmd := &cobra.Command{ Use: "generate-script", Short: "Generates a migration script", + Long: "Generates a PowerShell script that automates an Azure DevOps to GitHub migration.", + RunE: func(cmd *cobra.Command, _ []string) error { + log := getLogger(cmd) + envProv := env.New() + + adoPAT := a.adoPAT + if adoPAT == "" { + adoPAT = envProv.ADOPAT() + } + + adoServerURL := a.adoServerURL + if adoServerURL == "" { + adoServerURL = "https://dev.azure.com" + } + + client := ado.NewClient(adoServerURL, adoPAT, log) + ins := ado.NewInspector(log, client) + ins.OrgFilter = a.adoOrg + ins.TeamProjectFilter = a.adoTeamProject + + return runGenerateScript(cmd.Context(), client, ins, log, a, defaultWriteToFile) + }, } + + // Required flags + cmd.Flags().StringVar(&a.githubOrg, "github-org", "", "Target GitHub organization name (REQUIRED)") + + // Optional flags + cmd.Flags().StringVar(&a.adoOrg, "ado-org", "", "Azure DevOps organization name") + cmd.Flags().StringVar(&a.adoTeamProject, "ado-team-project", "", "Azure DevOps team project name") + cmd.Flags().StringVar(&a.output, "output", "./migrate.ps1", "Output file path for the migration script") + cmd.Flags().BoolVar(&a.sequential, "sequential", false, "Generate a sequential (non-parallel) script") + cmd.Flags().StringVar(&a.adoServerURL, "ado-server-url", "", "Azure DevOps Server URL") + cmd.Flags().StringVar(&a.targetAPIURL, "target-api-url", "", "API URL for the target GitHub instance") + cmd.Flags().BoolVar(&a.createTeams, "create-teams", false, "Include team creation and assignment scripts") + cmd.Flags().BoolVar(&a.linkIdpGroups, "link-idp-groups", false, "Link IdP groups to teams") + cmd.Flags().BoolVar(&a.lockAdoRepos, "lock-ado-repos", false, "Lock ADO repos before migration") + cmd.Flags().BoolVar(&a.disableAdoRepos, "disable-ado-repos", false, "Disable ADO repos after migration") + cmd.Flags().BoolVar(&a.rewirePipelines, "rewire-pipelines", false, "Rewire Azure Pipelines to GitHub repos") + cmd.Flags().BoolVar(&a.downloadMigrationLogs, "download-migration-logs", false, "Download migration logs after migration") + cmd.Flags().BoolVar(&a.all, "all", false, "Enable all optional migration steps") + cmd.Flags().StringVar(&a.repoList, "repo-list", "", "Path to a CSV file with repos to migrate") + cmd.Flags().StringVar(&a.adoPAT, "ado-pat", "", "") + + // Hidden flags + _ = cmd.Flags().MarkHidden("ado-server-url") + _ = cmd.Flags().MarkHidden("ado-pat") + + return cmd } // --------------------------------------------------------------------------- @@ -749,6 +800,6 @@ func wrap(script string) string { } // defaultWriteToFile writes content to a file (production implementation). -func defaultWriteToFile(path, content string) error { //nolint:unused // will be used when newGenerateScriptCmdLive is fully wired +func defaultWriteToFile(path, content string) error { return os.WriteFile(path, []byte(content), 0o600) } diff --git a/cmd/ado2gh/main.go b/cmd/ado2gh/main.go index 92d2f89dc..cc29f21d9 100644 --- a/cmd/ado2gh/main.go +++ b/cmd/ado2gh/main.go @@ -2,10 +2,13 @@ package main import ( "context" + "errors" + "fmt" "net/http" "os" "strings" + "github.com/github/gh-gei/internal/cmdutil" "github.com/github/gh-gei/pkg/env" "github.com/github/gh-gei/pkg/logger" "github.com/github/gh-gei/pkg/status" @@ -24,7 +27,18 @@ var ( ) func main() { - if err := newRootCmd().Execute(); err != nil { + rootCmd := newRootCmd() + if err := rootCmd.Execute(); err != nil { + if log, ok := rootCmd.Context().Value(loggerKey).(*logger.Logger); ok && log != nil { + var userErr *cmdutil.UserError + if errors.As(err, &userErr) { + log.Errorf("%v", err) + } else { + log.Errorf("Unexpected error: %v", err) + } + } else { + fmt.Fprintf(os.Stderr, "[ERROR] %v\n", err) + } os.Exit(1) } } diff --git a/cmd/ado2gh/wiring.go b/cmd/ado2gh/wiring.go index 6226ea9fc..1ae062025 100644 --- a/cmd/ado2gh/wiring.go +++ b/cmd/ado2gh/wiring.go @@ -163,8 +163,8 @@ func newDownloadLogsCmdLive() *cobra.Command { } cmd.Flags().StringVar(&migrationID, "migration-id", "", "The ID of the migration") - cmd.Flags().StringVar(&githubTargetOrg, "github-target-org", "", "Target GitHub organization") - cmd.Flags().StringVar(&targetRepo, "target-repo", "", "Target repository name") + cmd.Flags().StringVar(&githubTargetOrg, "github-org", "", "Target GitHub organization") + cmd.Flags().StringVar(&targetRepo, "github-repo", "", "Target repository name") cmd.Flags().StringVar(&logFile, "migration-log-file", "", "Custom output filename for the migration log") cmd.Flags().BoolVar(&overwrite, "overwrite", false, "Overwrite the log file if it already exists") cmd.Flags().StringVar(&githubTargetPAT, "github-target-pat", "", "Personal access token for the target GitHub instance") diff --git a/cmd/bbs2gh/main.go b/cmd/bbs2gh/main.go index f8b9ac60c..e761cb571 100644 --- a/cmd/bbs2gh/main.go +++ b/cmd/bbs2gh/main.go @@ -2,10 +2,13 @@ package main import ( "context" + "errors" + "fmt" "net/http" "os" "strings" + "github.com/github/gh-gei/internal/cmdutil" "github.com/github/gh-gei/pkg/env" "github.com/github/gh-gei/pkg/logger" "github.com/github/gh-gei/pkg/status" @@ -24,7 +27,18 @@ var ( ) func main() { - if err := newRootCmd().Execute(); err != nil { + rootCmd := newRootCmd() + if err := rootCmd.Execute(); err != nil { + if log, ok := rootCmd.Context().Value(loggerKey).(*logger.Logger); ok && log != nil { + var userErr *cmdutil.UserError + if errors.As(err, &userErr) { + log.Errorf("%v", err) + } else { + log.Errorf("Unexpected error: %v", err) + } + } else { + fmt.Fprintf(os.Stderr, "[ERROR] %v\n", err) + } os.Exit(1) } } diff --git a/cmd/bbs2gh/migrate_repo.go b/cmd/bbs2gh/migrate_repo.go index f9d0ec6a4..465f81f52 100644 --- a/cmd/bbs2gh/migrate_repo.go +++ b/cmd/bbs2gh/migrate_repo.go @@ -4,7 +4,9 @@ import ( "context" "fmt" "io" + "net/http" "net/url" + "strconv" "strings" "time" @@ -19,6 +21,7 @@ import ( "github.com/github/gh-gei/pkg/migration" awsStorage "github.com/github/gh-gei/pkg/storage/aws" azureStorage "github.com/github/gh-gei/pkg/storage/azure" + "github.com/github/gh-gei/pkg/storage/ghowned" "github.com/google/uuid" "github.com/spf13/cobra" ) @@ -127,6 +130,7 @@ type bbsMigrateRepoArgs struct { awsRegion string keepArchive bool targetAPIURL string + targetUploadsURL string queueOnly bool useGithubStorage bool } @@ -393,7 +397,7 @@ func validateBbsUploadOptions(a *bbsMigrateRepoArgs, envProv bbsMigrateRepoEnvPr shouldUseAzure := resolveBbsAzureConnectionString(a.azureStorageConnectionString, envProv) != "" shouldUseAWS := a.awsBucketName != "" - if err := validateBbsUploadConflicts(a, shouldUseAzure, shouldUseAWS, envProv); err != nil { + if err := validateBbsUploadConflicts(a, shouldUseAzure, shouldUseAWS); err != nil { return err } @@ -404,8 +408,8 @@ func validateBbsUploadOptions(a *bbsMigrateRepoArgs, envProv bbsMigrateRepoEnvPr return nil } -func validateBbsUploadConflicts(a *bbsMigrateRepoArgs, shouldUseAzure, shouldUseAWS bool, envProv bbsMigrateRepoEnvProvider) error { - if !shouldUseAWS && hasAWSSubOptions(a, envProv) { +func validateBbsUploadConflicts(a *bbsMigrateRepoArgs, shouldUseAzure, shouldUseAWS bool) error { + if !shouldUseAWS && hasAWSSubOptions(a) { return cmdutil.NewUserError("The AWS S3 bucket name must be provided with --aws-bucket-name if other AWS S3 upload options are set.") } if a.useGithubStorage && shouldUseAWS { @@ -427,11 +431,11 @@ func validateBbsUploadConflicts(a *bbsMigrateRepoArgs, shouldUseAzure, shouldUse return nil } -func hasAWSSubOptions(a *bbsMigrateRepoArgs, envProv bbsMigrateRepoEnvProvider) bool { - return a.awsAccessKey != "" || resolveBbsAWSAccessKey("", envProv) != "" || - a.awsSecretKey != "" || resolveBbsAWSSecretKey("", envProv) != "" || - a.awsSessionToken != "" || envProv.AWSSessionToken() != "" || - a.awsRegion != "" || resolveBbsAWSRegion("", envProv) != "" +func hasAWSSubOptions(a *bbsMigrateRepoArgs) bool { + return a.awsAccessKey != "" || + a.awsSecretKey != "" || + a.awsSessionToken != "" || + a.awsRegion != "" } func validateBbsAWSCredentials(a *bbsMigrateRepoArgs, envProv bbsMigrateRepoEnvProvider) error { @@ -660,13 +664,13 @@ func bbsImportArchive( if migration.IsRepoFailed(m.State) { log.Errorf("Migration Failed. Migration ID: %s", migrationID) sharedcmd.LogWarningsCount(log, m.WarningsCount) - log.Info("Migration log available at %s or by running `gh bbs2gh download-logs --github-target-org %s --target-repo %s`", m.MigrationLogURL, a.githubOrg, a.githubRepo) + log.Info("Migration log available at %s or by running `gh bbs2gh download-logs --github-org %s --github-repo %s`", m.MigrationLogURL, a.githubOrg, a.githubRepo) return cmdutil.NewUserError(m.FailureReason) } log.Success("Migration completed (ID: %s)! State: %s", migrationID, m.State) sharedcmd.LogWarningsCount(log, m.WarningsCount) - log.Info("Migration log available at %s or by running `gh bbs2gh download-logs --github-target-org %s --target-repo %s`", m.MigrationLogURL, a.githubOrg, a.githubRepo) + log.Info("Migration log available at %s or by running `gh bbs2gh download-logs --github-org %s --github-repo %s`", m.MigrationLogURL, a.githubOrg, a.githubRepo) return nil } @@ -689,8 +693,7 @@ func pollBbsExport(ctx context.Context, bbsAPI bbsMigrateRepoBbsAPI, exportID in return nil } - if upper != "INITIALISING" && upper != "IN_PROGRESS" { //nolint:misspell // BBS API uses British spelling - // Error state + if upper == "FAILED" || upper == "ABORTED" { return cmdutil.NewUserErrorf("BBS export failed with state: %s - %s", exportState, message) } @@ -788,6 +791,17 @@ type awsLogAdapter struct { func (a *awsLogAdapter) LogInfo(format string, args ...interface{}) { a.log.Info(format, args...) } +// bbsTokenRoundTripper attaches a Bearer token to every outgoing request. +type bbsTokenRoundTripper struct { + token string +} + +func (t *bbsTokenRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + req = req.Clone(req.Context()) + req.Header.Set("Authorization", "Bearer "+t.token) + return http.DefaultTransport.RoundTrip(req) +} + // --------------------------------------------------------------------------- // Production command constructor (used by main.go) // --------------------------------------------------------------------------- @@ -859,6 +873,7 @@ func newMigrateRepoCmdLive() *cobra.Command { cmd.Flags().StringVar(&a.githubPAT, "github-pat", "", "Personal access token for the target GitHub instance") cmd.Flags().StringVar(&a.targetRepoVisibility, "target-repo-visibility", "", "Target repository visibility (public, private, internal)") cmd.Flags().StringVar(&a.targetAPIURL, "target-api-url", bbsDefaultTargetAPIURL, "API URL for the target GitHub instance") + cmd.Flags().StringVar(&a.targetUploadsURL, "target-uploads-url", "", "Uploads URL for the target GitHub instance") // Upload storage flags cmd.Flags().StringVar(&a.azureStorageConnectionString, "azure-storage-connection-string", "", "Azure Blob Storage connection string") @@ -873,6 +888,9 @@ func newMigrateRepoCmdLive() *cobra.Command { cmd.Flags().BoolVar(&a.queueOnly, "queue-only", false, "Queue the migration without waiting for completion") cmd.Flags().BoolVar(&a.useGithubStorage, "use-github-storage", false, "Use GitHub-owned storage for archives") + // Hidden flags + _ = cmd.Flags().MarkHidden("target-uploads-url") + return cmd } @@ -970,7 +988,42 @@ func buildBbsArchiveUploader(a *bbsMigrateRepoArgs, envProv bbsMigrateRepoEnvPro } if a.useGithubStorage { - log.Warning("GitHub-owned storage is not yet fully implemented in the Go port") + uploadsURL := a.targetUploadsURL + if uploadsURL == "" { + uploadsURL = "https://uploads.github.com" + } + + // Resolve target token for the ghowned HTTP client + targetToken := resolveBbsTargetToken(a.githubPAT, envProv) + + ghHTTPClient := &http.Client{ + Transport: &bbsTokenRoundTripper{token: targetToken}, + } + + var ghOwnedOpts []ghowned.Option + ghOwnedOpts = append(ghOwnedOpts, ghowned.WithLogger(log)) + + envReal := env.New() + if mebiStr := envReal.GitHubOwnedStorageMultipartMebibytes(); mebiStr != "" { + if mebi, err := strconv.ParseInt(mebiStr, 10, 64); err == nil { + ghOwnedOpts = append(ghOwnedOpts, ghowned.WithPartSizeMebibytes(mebi)) + } + } + + ghOwnedClient := ghowned.NewClient(uploadsURL, ghHTTPClient, ghOwnedOpts...) + + // Build a GitHub client for org ID resolution + tgtAPI := a.targetAPIURL + if tgtAPI == "" { + tgtAPI = bbsDefaultTargetAPIURL + } + targetGH := github.NewClient(targetToken, + github.WithAPIURL(tgtAPI), + github.WithLogger(log), + github.WithVersion(version), + ) + + uploaderOpts = append(uploaderOpts, archive.WithGitHub(ghOwnedClient, targetGH)) } return archive.NewUploader(uploaderOpts...), nil diff --git a/cmd/bbs2gh/migrate_repo_test.go b/cmd/bbs2gh/migrate_repo_test.go index cce44cc51..0179d0f96 100644 --- a/cmd/bbs2gh/migrate_repo_test.go +++ b/cmd/bbs2gh/migrate_repo_test.go @@ -900,6 +900,89 @@ func TestBbsMigrateRepo_ThrowsIfExportFails(t *testing.T) { assert.Contains(t, err.Error(), "BBS export failed") } +func TestBbsMigrateRepo_ThrowsIfExportAborted(t *testing.T) { + var buf bytes.Buffer + log := logger.New(false, &buf) + + bbsAPI := &mockBbsAPI{ + startExportResult: bbsExportID, + getExportStates: []struct { + state string + message string + percentage int + err error + }{ + {"ABORTED", "The export was aborted", 0, nil}, + }, + } + fs := &mockBbsFileSystem{fileExistsVal: true, dirExistsVal: true} + + cmd := newBbsMigrateRepoCmd(&mockBbsGitHub{}, bbsAPI, &mockBbsDownloader{}, &mockBbsUploader{}, fs, &mockBbsEnvProvider{}, log, defaultBbsOpts()) + cmd.SetOut(&buf) + cmd.SetErr(&buf) + cmd.SetArgs([]string{ + "--bbs-server-url", bbsServerURL, + "--bbs-username", bbsUsername, + "--bbs-password", bbsPassword, + "--bbs-project", bbsProject, + "--bbs-repo", bbsRepo, + }) + + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "BBS export failed") +} + +func TestBbsMigrateRepo_RunningStateIsInProgress(t *testing.T) { + var buf bytes.Buffer + log := logger.New(false, &buf) + + gh := &mockBbsGitHub{ + doesRepoExistResult: false, + getOrgIDResult: bbsGithubOrgID, + createMigrationSourceResult: bbsMigSourceID, + startBbsMigrationResult: bbsMigrationID, + getMigrationResults: []*github.Migration{ + {State: "SUCCEEDED", MigrationLogURL: "https://example.com/log"}, + }, + } + bbsAPI := &mockBbsAPI{ + startExportResult: bbsExportID, + getExportStates: []struct { + state string + message string + percentage int + err error + }{ + {"RUNNING", "Export is running", 50, nil}, + {"COMPLETED", "The export is complete", 100, nil}, + }, + } + downloader := &mockBbsDownloader{downloadResult: bbsArchivePath} + uploader := &mockBbsUploader{uploadResult: bbsArchiveURL} + envProv := &mockBbsEnvProvider{targetPAT: bbsGithubPAT} + fs := &mockBbsFileSystem{fileExistsVal: true, dirExistsVal: true, openReadContent: []byte("archive-data")} + + cmd := newBbsMigrateRepoCmd(gh, bbsAPI, downloader, uploader, fs, envProv, log, defaultBbsOpts()) + cmd.SetOut(&buf) + cmd.SetErr(&buf) + cmd.SetArgs([]string{ + "--bbs-server-url", bbsServerURL, + "--bbs-username", bbsUsername, + "--bbs-password", bbsPassword, + "--bbs-project", bbsProject, + "--bbs-repo", bbsRepo, + "--github-org", bbsGithubOrg, + "--github-repo", bbsGithubRepo, + "--azure-storage-connection-string", bbsAzureConnStr, + }) + + err := cmd.Execute() + require.NoError(t, err) + // RUNNING should have been polled through (2 GetExport calls: RUNNING then COMPLETED) + assert.Equal(t, 2, bbsAPI.getExportCallCount) +} + // --------------------------------------------------------------------------- // Tests: Archive Path Usage // --------------------------------------------------------------------------- diff --git a/cmd/bbs2gh/wiring.go b/cmd/bbs2gh/wiring.go index 43898dc45..b228abd26 100644 --- a/cmd/bbs2gh/wiring.go +++ b/cmd/bbs2gh/wiring.go @@ -163,8 +163,8 @@ func newDownloadLogsCmdLive() *cobra.Command { } cmd.Flags().StringVar(&migrationID, "migration-id", "", "The ID of the migration") - cmd.Flags().StringVar(&githubTargetOrg, "github-target-org", "", "Target GitHub organization") - cmd.Flags().StringVar(&targetRepo, "target-repo", "", "Target repository name") + cmd.Flags().StringVar(&githubTargetOrg, "github-org", "", "Target GitHub organization") + cmd.Flags().StringVar(&targetRepo, "github-repo", "", "Target repository name") cmd.Flags().StringVar(&logFile, "migration-log-file", "", "Custom output filename for the migration log") cmd.Flags().BoolVar(&overwrite, "overwrite", false, "Overwrite the log file if it already exists") cmd.Flags().StringVar(&githubTargetPAT, "github-target-pat", "", "Personal access token for the target GitHub instance") diff --git a/cmd/gei/migrate_repo.go b/cmd/gei/migrate_repo.go index 42b0f67b7..b8ffe43a7 100644 --- a/cmd/gei/migrate_repo.go +++ b/cmd/gei/migrate_repo.go @@ -4,9 +4,11 @@ import ( "context" "fmt" "io" + "net/http" "net/url" "os" "regexp" + "strconv" "strings" "time" @@ -18,6 +20,9 @@ import ( "github.com/github/gh-gei/pkg/github" "github.com/github/gh-gei/pkg/logger" "github.com/github/gh-gei/pkg/migration" + awsStorage "github.com/github/gh-gei/pkg/storage/aws" + azureStorage "github.com/github/gh-gei/pkg/storage/azure" + "github.com/github/gh-gei/pkg/storage/ghowned" "github.com/spf13/cobra" ) @@ -964,6 +969,24 @@ func (a *envProviderAdapter) AWSSecretAccessKey() string { return a.prov.AWSSecr func (a *envProviderAdapter) AWSSessionToken() string { return a.prov.AWSSessionToken() } func (a *envProviderAdapter) AWSRegion() string { return a.prov.AWSRegion() } +// tokenRoundTripper is an http.RoundTripper that injects a Bearer token. +type tokenRoundTripper struct { + token string +} + +func (t *tokenRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + req = req.Clone(req.Context()) + req.Header.Set("Authorization", "Bearer "+t.token) + return http.DefaultTransport.RoundTrip(req) +} + +// geiAwsLogAdapter adapts *logger.Logger to awsStorage.ProgressLogger (LogInfo method). +type geiAwsLogAdapter struct { + log *logger.Logger +} + +func (a *geiAwsLogAdapter) LogInfo(format string, args ...interface{}) { a.log.Info(format, args...) } + // --------------------------------------------------------------------------- // Production command constructor (used by main.go) // --------------------------------------------------------------------------- @@ -1075,15 +1098,80 @@ func newMigrateRepoCmdLive() *cobra.Command { // Build archive uploader with resolved credentials uploaderOpts := []archive.UploaderOption{archive.WithLogger(log)} - // NOTE: Azure and AWS storage backends require their respective client - // packages. When those packages are fully integrated, add: - // if connStr != "" { uploaderOpts = append(uploaderOpts, archive.WithAzure(...)) } - // if awsBucketName != "" { uploaderOpts = append(uploaderOpts, archive.WithAWS(...)) } + + // Azure Storage backend + azureConnStr := a.azureStorageConnectionString + if azureConnStr == "" { + azureConnStr = envProv.AzureStorageConnectionString() + } + if azureConnStr != "" { + azClient, err := azureStorage.NewClient(azureConnStr, log) + if err != nil { + return fmt.Errorf("initializing Azure storage client: %w", err) + } + uploaderOpts = append(uploaderOpts, archive.WithAzure(azClient)) + } + + // AWS S3 backend + if a.awsBucketName != "" { + awsAccessKey := a.awsAccessKey + if awsAccessKey == "" { + awsAccessKey = envProv.AWSAccessKeyID() + } + awsSecretKey := a.awsSecretKey + if awsSecretKey == "" { + awsSecretKey = envProv.AWSSecretAccessKey() + } + + var awsOpts []awsStorage.Option + awsRegion := a.awsRegion + if awsRegion == "" { + awsRegion = envProv.AWSRegion() + } + if awsRegion != "" { + awsOpts = append(awsOpts, awsStorage.WithRegion(awsRegion)) + } + awsSessionToken := a.awsSessionToken + if awsSessionToken == "" { + awsSessionToken = envProv.AWSSessionToken() + } + if awsSessionToken != "" { + awsOpts = append(awsOpts, awsStorage.WithSessionToken(awsSessionToken)) + } + awsOpts = append(awsOpts, awsStorage.WithLogger(&geiAwsLogAdapter{log: log})) + + awsClient, err := awsStorage.NewClient(awsAccessKey, awsSecretKey, awsOpts...) + if err != nil { + return fmt.Errorf("initializing AWS S3 client: %w", err) + } + uploaderOpts = append(uploaderOpts, archive.WithAWS(awsClient, a.awsBucketName)) + } + + // GitHub-owned storage backend if a.useGithubStorage { - // TODO: GitHub-owned storage requires Upload method on github.Client - // which has not been implemented yet. This is a hidden flag. - log.Warning("GitHub-owned storage is not yet fully implemented in the Go port") + uploadsURL := a.targetUploadsURL + if uploadsURL == "" { + uploadsURL = "https://uploads.github.com" + } + + ghHTTPClient := &http.Client{ + Transport: &tokenRoundTripper{token: targetToken}, + } + + var ghOwnedOpts []ghowned.Option + ghOwnedOpts = append(ghOwnedOpts, ghowned.WithLogger(log)) + + envReal := env.New() + if mebiStr := envReal.GitHubOwnedStorageMultipartMebibytes(); mebiStr != "" { + if mebi, err := strconv.ParseInt(mebiStr, 10, 64); err == nil { + ghOwnedOpts = append(ghOwnedOpts, ghowned.WithPartSizeMebibytes(mebi)) + } + } + + ghOwnedClient := ghowned.NewClient(uploadsURL, ghHTTPClient, ghOwnedOpts...) + uploaderOpts = append(uploaderOpts, archive.WithGitHub(ghOwnedClient, targetGH)) } + uploader := archive.NewUploader(uploaderOpts...) downloader := download.New(nil) // default HTTP client diff --git a/pkg/ado/client.go b/pkg/ado/client.go index 53f7568ef..46696f4ba 100644 --- a/pkg/ado/client.go +++ b/pkg/ado/client.go @@ -19,6 +19,25 @@ import ( const nullStr = "null" +// jsonValueToString converts a JSON value (which may be a string, boolean, +// number, or null) to its lowercase string representation. The ADO API +// sometimes returns boolean literals (e.g. `false`) for fields that are +// conceptually strings (like checkoutSubmodules and clean). The C# JToken +// cast `(string)value` handles this transparently; this helper replicates +// that behavior for Go's strict JSON unmarshalling. +func jsonValueToString(raw json.RawMessage) string { + if len(raw) == 0 || string(raw) == "null" { + return nullStr + } + // Try string first (most common case). + var s string + if err := json.Unmarshal(raw, &s); err == nil { + return strings.ToLower(s) + } + // Fall back: booleans, numbers, etc. — use the raw literal. + return strings.ToLower(strings.Trim(string(raw), `"`)) +} + // Client is a complete Azure DevOps API client. // It corresponds to the combination of C# AdoClient + AdoApi. type Client struct { @@ -1314,9 +1333,9 @@ func (c *Client) GetPipeline(ctx context.Context, org, teamProject string, pipel var data struct { Repository struct { - DefaultBranch string `json:"defaultBranch"` - Clean *string `json:"clean"` - CheckoutSubmodules *string `json:"checkoutSubmodules"` + DefaultBranch string `json:"defaultBranch"` + Clean json.RawMessage `json:"clean"` + CheckoutSubmodules json.RawMessage `json:"checkoutSubmodules"` } `json:"repository"` Triggers json.RawMessage `json:"triggers"` } @@ -1329,14 +1348,8 @@ func (c *Client) GetPipeline(ctx context.Context, org, teamProject string, pipel defaultBranch = defaultBranch[len("refs/heads/"):] } - clean := nullStr - if data.Repository.Clean != nil { - clean = strings.ToLower(*data.Repository.Clean) - } - checkout := nullStr - if data.Repository.CheckoutSubmodules != nil { - checkout = strings.ToLower(*data.Repository.CheckoutSubmodules) - } + clean := jsonValueToString(data.Repository.Clean) + checkout := jsonValueToString(data.Repository.CheckoutSubmodules) return PipelineInfo{ DefaultBranch: defaultBranch, @@ -1378,11 +1391,11 @@ func (c *Client) GetPipelineRepository(ctx context.Context, org, teamProject str var data struct { Repository struct { - Name string `json:"name"` - ID string `json:"id"` - DefaultBranch string `json:"defaultBranch"` - Clean *string `json:"clean"` - CheckoutSubmodules *string `json:"checkoutSubmodules"` + Name string `json:"name"` + ID string `json:"id"` + DefaultBranch string `json:"defaultBranch"` + Clean json.RawMessage `json:"clean"` + CheckoutSubmodules json.RawMessage `json:"checkoutSubmodules"` } `json:"repository"` } if err := json.Unmarshal([]byte(body), &data); err != nil { @@ -1394,14 +1407,8 @@ func (c *Client) GetPipelineRepository(ctx context.Context, org, teamProject str defaultBranch = defaultBranch[len("refs/heads/"):] } - clean := nullStr - if data.Repository.Clean != nil { - clean = strings.ToLower(*data.Repository.Clean) - } - checkout := nullStr - if data.Repository.CheckoutSubmodules != nil { - checkout = strings.ToLower(*data.Repository.CheckoutSubmodules) - } + clean := jsonValueToString(data.Repository.Clean) + checkout := jsonValueToString(data.Repository.CheckoutSubmodules) return PipelineRepository{ RepoName: data.Repository.Name, diff --git a/pkg/ado/client_test.go b/pkg/ado/client_test.go index 279641e9e..71b55f444 100644 --- a/pkg/ado/client_test.go +++ b/pkg/ado/client_test.go @@ -540,8 +540,8 @@ func TestGetRepos(t *testing.T) { assert.Contains(t, r.URL.Path, "/org/proj/_apis/git/repositories") w.WriteHeader(200) fmt.Fprint(w, `{"value":[ - {"id":"r1","name":"Repo1","size":"100","isDisabled":"false"}, - {"id":"r2","name":"Repo2","size":"200","isDisabled":"true"} + {"id":"r1","name":"Repo1","size":100,"isDisabled":false}, + {"id":"r2","name":"Repo2","size":200,"isDisabled":true} ]}`) }) @@ -561,9 +561,9 @@ func TestGetEnabledRepos(t *testing.T) { c, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) fmt.Fprint(w, `{"value":[ - {"id":"r1","name":"Repo1","size":"100","isDisabled":"false"}, - {"id":"r2","name":"Repo2","size":"200","isDisabled":"true"}, - {"id":"r3","name":"Repo3","size":"50","isDisabled":"false"} + {"id":"r1","name":"Repo1","size":100,"isDisabled":false}, + {"id":"r2","name":"Repo2","size":200,"isDisabled":true}, + {"id":"r3","name":"Repo3","size":50,"isDisabled":false} ]}`) }) diff --git a/pkg/ado/models.go b/pkg/ado/models.go index 10ee506aa..4a713300f 100644 --- a/pkg/ado/models.go +++ b/pkg/ado/models.go @@ -16,8 +16,8 @@ type TeamProject struct { type Repository struct { ID string `json:"id"` Name string `json:"name"` - Size uint64 `json:"size,string"` // ADO returns size as string in paginated response - IsDisabled bool `json:"isDisabled,string"` + Size uint64 `json:"size"` + IsDisabled bool `json:"isDisabled"` } // BoardsConnection holds an Azure Boards ↔ GitHub external connection. diff --git a/pkg/ado/pipeline_trigger_service.go b/pkg/ado/pipeline_trigger_service.go index 2163641eb..c938ce7a3 100644 --- a/pkg/ado/pipeline_trigger_service.go +++ b/pkg/ado/pipeline_trigger_service.go @@ -341,7 +341,7 @@ func (s *PipelineTriggerService) createBranchPolicyRequiredTriggers(originalTrig } func (s *PipelineTriggerService) createStandardTriggers(originalTriggers json.RawMessage) interface{} { - if originalTriggers != nil && string(originalTriggers) != "null" { + if originalTriggers != nil && string(originalTriggers) != nullStr { hadPullRequestTrigger := s.hasPullRequestTrigger(originalTriggers) originalCiReport := s.getOriginalReportBuildStatus(originalTriggers, "continuousIntegration") originalPrReport := s.getOriginalReportBuildStatus(originalTriggers, "pullRequest") @@ -412,7 +412,7 @@ func (s *PipelineTriggerService) hasPullRequestTrigger(originalTriggers json.Raw } func (s *PipelineTriggerService) getOriginalReportBuildStatus(originalTriggers json.RawMessage, triggerType string) bool { - if originalTriggers == nil || string(originalTriggers) == "null" { + if originalTriggers == nil || string(originalTriggers) == nullStr { return true // Default to true when no original triggers exist } diff --git a/pkg/github/client.go b/pkg/github/client.go index 620a7602d..aad85d483 100644 --- a/pkg/github/client.go +++ b/pkg/github/client.go @@ -866,29 +866,38 @@ func (c *Client) AddTeamToRepo(ctx context.Context, org, teamSlug, repo, role st } // GetIdpGroupId looks up the external group ID for a given group name (case-insensitive). +// It paginates through all pages of external groups to find the match. func (c *Client) GetIdpGroupId(ctx context.Context, org, groupName string) (int, error) { - u := fmt.Sprintf("orgs/%s/external-groups", org) + page := 1 + for { + u := fmt.Sprintf("orgs/%s/external-groups?per_page=100&page=%d", org, page) - req, err := c.rest.NewRequest("GET", u, nil) - if err != nil { - return 0, fmt.Errorf("failed to create external groups request: %w", err) - } + req, err := c.rest.NewRequest("GET", u, nil) + if err != nil { + return 0, fmt.Errorf("failed to create external groups request: %w", err) + } - var result struct { - Groups []struct { - GroupID int `json:"group_id"` - GroupName string `json:"group_name"` - } `json:"groups"` - } - _, err = c.rest.Do(ctx, req, &result) - if err != nil { - return 0, fmt.Errorf("failed to get external groups for %q: %w", org, err) - } + var result struct { + Groups []struct { + GroupID int `json:"group_id"` + GroupName string `json:"group_name"` + } `json:"groups"` + } + resp, err := c.rest.Do(ctx, req, &result) + if err != nil { + return 0, fmt.Errorf("failed to get external groups for %q: %w", org, err) + } - for _, g := range result.Groups { - if strings.EqualFold(g.GroupName, groupName) { - return g.GroupID, nil + for _, g := range result.Groups { + if strings.EqualFold(g.GroupName, groupName) { + return g.GroupID, nil + } } + + if resp.NextPage == 0 { + break + } + page = resp.NextPage } return 0, fmt.Errorf("external group %q not found in organization %q", groupName, org) diff --git a/pkg/github/client_test.go b/pkg/github/client_test.go index c9f7a0322..4902bfc39 100644 --- a/pkg/github/client_test.go +++ b/pkg/github/client_test.go @@ -892,6 +892,8 @@ func TestClient_GetIdpGroupId(t *testing.T) { t.Run("found", func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Contains(t, r.URL.Path, "external-groups") + assert.Equal(t, "100", r.URL.Query().Get("per_page")) + assert.Equal(t, "1", r.URL.Query().Get("page")) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) fmt.Fprint(w, `{"groups":[{"group_id":42,"group_name":"Test Group"}]}`) @@ -919,6 +921,34 @@ func TestClient_GetIdpGroupId(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "not found") }) + + t.Run("pagination", func(t *testing.T) { + callCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + w.Header().Set("Content-Type", "application/json") + if callCount == 1 { + assert.Equal(t, "1", r.URL.Query().Get("page")) + // First page: include Link header pointing to next page + w.Header().Set("Link", `<`+r.URL.Path+`?per_page=100&page=2>; rel="next"`) + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, `{"groups":[{"group_id":1,"group_name":"Other Group"}]}`) + } else { + assert.Equal(t, "2", r.URL.Query().Get("page")) + // Second page: target group present, no next link + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, `{"groups":[{"group_id":99,"group_name":"Target Group"}]}`) + } + })) + defer server.Close() + + client := newTestClient(t, server) + groupID, err := client.GetIdpGroupId(context.Background(), "test-org", "Target Group") + + require.NoError(t, err) + assert.Equal(t, 99, groupID) + assert.Equal(t, 2, callCount) + }) } func TestClient_AddEmuGroupToTeam(t *testing.T) { diff --git a/pkg/storage/ghowned/client.go b/pkg/storage/ghowned/client.go index 704d5fa6d..7fd399c58 100644 --- a/pkg/storage/ghowned/client.go +++ b/pkg/storage/ghowned/client.go @@ -102,6 +102,7 @@ func (c *Client) uploadSingle(ctx context.Context, orgDatabaseID, archiveName st return "", cmdutil.WrapUserError("failed to create upload request", err) } req.ContentLength = size + req.Header.Set("Content-Type", "application/octet-stream") resp, err := c.httpClient.Do(req) if err != nil { @@ -110,7 +111,8 @@ func (c *Client) uploadSingle(ctx context.Context, orgDatabaseID, archiveName st defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return "", cmdutil.NewUserErrorf("upload failed with status %d", resp.StatusCode) + respBody, _ := io.ReadAll(resp.Body) + return "", cmdutil.NewUserErrorf("upload failed with status %d: %s", resp.StatusCode, string(respBody)) } return c.parseURIResponse(resp.Body) @@ -179,7 +181,8 @@ func (c *Client) multipartStart(ctx context.Context, orgDatabaseID, archiveName defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return "", cmdutil.NewUserErrorf("multipart start failed with status %d", resp.StatusCode) + respBody, _ := io.ReadAll(resp.Body) + return "", cmdutil.NewUserErrorf("multipart start failed with status %d: %s", resp.StatusCode, string(respBody)) } return c.getNextURL(resp) @@ -201,7 +204,8 @@ func (c *Client) multipartPart(ctx context.Context, patchURL string, data []byte defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return "", cmdutil.NewUserErrorf("part upload failed with status %d", resp.StatusCode) + respBody, _ := io.ReadAll(resp.Body) + return "", cmdutil.NewUserErrorf("part upload failed with status %d: %s", resp.StatusCode, string(respBody)) } return c.getNextURL(resp) @@ -221,7 +225,8 @@ func (c *Client) multipartComplete(ctx context.Context, putURL string) (string, defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return "", cmdutil.NewUserErrorf("multipart complete failed with status %d", resp.StatusCode) + respBody, _ := io.ReadAll(resp.Body) + return "", cmdutil.NewUserErrorf("multipart complete failed with status %d: %s", resp.StatusCode, string(respBody)) } return c.parseURIResponse(resp.Body) @@ -249,7 +254,7 @@ func (c *Client) getNextURL(resp *http.Response) (string, error) { // parseURIResponse reads a JSON response body and extracts the "uri" field. func (c *Client) parseURIResponse(body io.Reader) (string, error) { - var result map[string]string + var result map[string]interface{} if err := json.NewDecoder(body).Decode(&result); err != nil { return "", cmdutil.WrapUserError("failed to parse upload response", err) } @@ -257,7 +262,11 @@ func (c *Client) parseURIResponse(body io.Reader) (string, error) { if !ok { return "", cmdutil.NewUserError("upload response missing 'uri' field") } - return uri, nil + uriStr, ok := uri.(string) + if !ok { + return "", cmdutil.NewUserError("upload response 'uri' field is not a string") + } + return uriStr, nil } // logInfo logs an informational message if a logger is configured. diff --git a/pkg/storage/ghowned/client_test.go b/pkg/storage/ghowned/client_test.go index a3d9f2366..3fc00d1fb 100644 --- a/pkg/storage/ghowned/client_test.go +++ b/pkg/storage/ghowned/client_test.go @@ -57,7 +57,7 @@ func TestUpload_SmallArchive_SinglePost(t *testing.T) { receivedPath = r.URL.Path + "?" + r.URL.RawQuery receivedBody, _ = io.ReadAll(r.Body) w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"uri": expectedURI}) + json.NewEncoder(w).Encode(map[string]interface{}{"uri": expectedURI, "size": 1024}) })) defer srv.Close() @@ -80,7 +80,7 @@ func TestUpload_SmallArchive_ExactlyAtLimit(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"uri": expectedURI}) + json.NewEncoder(w).Encode(map[string]interface{}{"uri": expectedURI, "size": 1024}) })) defer srv.Close() @@ -138,7 +138,7 @@ func TestUpload_LargeArchive_MultipartUpload(t *testing.T) { } if r.Method == http.MethodPut && path == "/upload/complete" { w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"uri": expectedURI}) + json.NewEncoder(w).Encode(map[string]interface{}{"uri": expectedURI, "size": 1024}) return } w.WriteHeader(http.StatusNotFound) @@ -258,7 +258,7 @@ func TestUpload_LargeArchive_RelativeLocationHeader(t *testing.T) { } if r.Method == http.MethodPut && r.URL.Path == "/relative/complete" { w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"uri": expectedURI}) + json.NewEncoder(w).Encode(map[string]interface{}{"uri": expectedURI, "size": 1024}) return } w.WriteHeader(http.StatusNotFound) @@ -317,7 +317,7 @@ func TestUpload_WithLogger(t *testing.T) { } if r.Method == http.MethodPut && r.URL.Path == testUploadDone { w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"uri": expectedURI}) + json.NewEncoder(w).Encode(map[string]interface{}{"uri": expectedURI, "size": 1024}) return } })) @@ -395,7 +395,7 @@ func TestUpload_PatchContentType(t *testing.T) { } case http.MethodPut: w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"uri": "gei://test"}) + json.NewEncoder(w).Encode(map[string]interface{}{"uri": "gei://test", "size": 1024}) } })) defer srv.Close()