From 7490da32ad3019ab2e3a67fd7ebf6d8d5b141d16 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Feb 2026 11:48:32 +0000 Subject: [PATCH 1/4] Initial plan From d9a45701cfe71c74b564c15276c926d7b7cafa90 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Feb 2026 11:59:32 +0000 Subject: [PATCH 2/4] Fix: resolve major-only --to version to latest release for that major Co-authored-by: ReneWerner87 <7063188+ReneWerner87@users.noreply.github.com> --- cmd/migrate.go | 9 +++++++++ cmd/migrate_test.go | 10 ++++++++-- cmd/version.go | 41 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 2 deletions(-) diff --git a/cmd/migrate.go b/cmd/migrate.go index 1ab98b3..7140e98 100644 --- a/cmd/migrate.go +++ b/cmd/migrate.go @@ -107,6 +107,15 @@ func migrateRunE(cmd *cobra.Command, opts MigrateOptions) error { } } opts.TargetVersionS = strings.TrimPrefix(opts.TargetVersionS, "v") + // If only a major version is given (e.g. "--to v3"), resolve to the latest release for that major. + if !strings.Contains(opts.TargetVersionS, ".") { + if major, parseErr := strconv.ParseUint(opts.TargetVersionS, 10, 64); parseErr == nil { + opts.TargetVersionS, err = LatestFiberVersionForMajor(major) + if err != nil { + return fmt.Errorf("failed to determine latest fiber version for major %d: %w", major, err) + } + } + } baseVersion, err := semver.NewVersion(opts.TargetVersionS) if err != nil { return fmt.Errorf("invalid version for \"%s\": %w", opts.TargetVersionS, err) diff --git a/cmd/migrate_test.go b/cmd/migrate_test.go index bc1a1f8..0354c68 100644 --- a/cmd/migrate_test.go +++ b/cmd/migrate_test.go @@ -219,16 +219,22 @@ func main() { require.NoError(t, os.Chdir(dir)) defer func() { require.NoError(t, os.Chdir(cwd)) }() + httpmock.Activate() + defer httpmock.DeactivateAndReset() + clearHTTPCache() + httpmock.RegisterResponder(http.MethodGet, "https://api.github.com/repos/gofiber/fiber/releases?per_page=100", + httpmock.NewBytesResponder(200, []byte(`[{"tag_name":"v3.1.0","prerelease":false,"draft":false},{"tag_name":"v3.0.0","prerelease":false,"draft":false}]`))) + cmd := newMigrateCmd() setupCmd() defer teardownCmd() out, err := runCobraCmd(cmd, "-t=3") require.NoError(t, err) - assert.Contains(t, out, "Migration from Fiber 2.0.6 to 3.0.0") + assert.Contains(t, out, "Migration from Fiber 2.0.6 to 3.1.0") content := readFileTB(t, filepath.Join(dir, "go.mod")) - assert.Contains(t, content, "github.com/gofiber/fiber/v3 v3.0.0") + assert.Contains(t, content, "github.com/gofiber/fiber/v3 v3.1.0") } func Test_RunGoMod(t *testing.T) { diff --git a/cmd/version.go b/cmd/version.go index 1447ca7..e910fcb 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -2,6 +2,7 @@ package cmd import ( "context" + "encoding/json" "errors" "fmt" "net/http" @@ -81,6 +82,46 @@ func LatestCliVersion() (string, error) { return latestVersionByURL("https://api.github.com/repos/gofiber/cli/releases/latest") } +// LatestFiberVersionForMajor retrieves the most recent non-prerelease Fiber release for a given major version. +func LatestFiberVersionForMajor(major uint64) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + url := "https://api.github.com/repos/gofiber/fiber/releases?per_page=100" + b, status, err := cachedGET(ctx, url, nil) + if err != nil { + return "", fmt.Errorf("http request failed: %w", err) + } + if status != http.StatusOK { + msg := strings.TrimSpace(string(b)) + if msg == "" { + msg = http.StatusText(status) + } + return "", fmt.Errorf("http request failed: %s", msg) + } + + var releases []struct { + TagName string `json:"tag_name"` + Prerelease bool `json:"prerelease"` + Draft bool `json:"draft"` + } + if err := json.Unmarshal(b, &releases); err != nil { + return "", fmt.Errorf("decode response: %w", err) + } + + prefix := fmt.Sprintf("v%d.", major) + for _, r := range releases { + if r.Draft || r.Prerelease { + continue + } + if strings.HasPrefix(r.TagName, prefix) { + return strings.TrimPrefix(r.TagName, "v"), nil + } + } + + return "", fmt.Errorf("no release found for major version %d", major) +} + func latestVersionByURL(url string) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() From adab36446d69fdabbb1bcae5bddec5edbe2583f9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Feb 2026 16:01:31 +0000 Subject: [PATCH 3/4] Fix: generalize partial/wildcard version resolution using semver constraints Co-authored-by: ReneWerner87 <7063188+ReneWerner87@users.noreply.github.com> --- cmd/migrate.go | 56 +++++++++++++++++++--- cmd/migrate_test.go | 112 ++++++++++++++++++++++++++++++++++---------- cmd/version.go | 14 ++++-- 3 files changed, 144 insertions(+), 38 deletions(-) diff --git a/cmd/migrate.go b/cmd/migrate.go index 7140e98..c046d25 100644 --- a/cmd/migrate.go +++ b/cmd/migrate.go @@ -107,14 +107,14 @@ func migrateRunE(cmd *cobra.Command, opts MigrateOptions) error { } } opts.TargetVersionS = strings.TrimPrefix(opts.TargetVersionS, "v") - // If only a major version is given (e.g. "--to v3"), resolve to the latest release for that major. - if !strings.Contains(opts.TargetVersionS, ".") { - if major, parseErr := strconv.ParseUint(opts.TargetVersionS, 10, 64); parseErr == nil { - opts.TargetVersionS, err = LatestFiberVersionForMajor(major) - if err != nil { - return fmt.Errorf("failed to determine latest fiber version for major %d: %w", major, err) - } + // Resolve partial/wildcard versions (e.g. "3", "3.*", "3.1", "3.1.x") to the + // latest matching release from GitHub. + if constraint, parseErr := partialVersionConstraint(opts.TargetVersionS); parseErr == nil { + resolved, resolveErr := LatestFiberVersionForConstraint(constraint) + if resolveErr != nil { + return fmt.Errorf("failed to resolve version %q: %w", opts.TargetVersionS, resolveErr) } + opts.TargetVersionS = resolved } baseVersion, err := semver.NewVersion(opts.TargetVersionS) if err != nil { @@ -250,3 +250,45 @@ func pseudoVersionFromHash(repo string, base *semver.Version, hash string) (stri pv := module.PseudoVersion("v"+strconv.FormatUint(base.Major(), 10), "v"+base.String(), commitTime, short) return strings.TrimPrefix(pv, "v"), nil } + +// partialVersionConstraint builds a semver constraint for partial or wildcard version strings such as +// "3", "3.*", "3.x", "3.1", "3.1.*", "3.1.x". It returns an error for full semver strings (x.y.z) +// or strings with pre-release/build metadata, which should be used as-is. +func partialVersionConstraint(v string) (*semver.Constraints, error) { + // Normalize trailing wildcard segments in order (longest first). + v = strings.TrimSuffix(v, ".*.*") + v = strings.TrimSuffix(v, ".x.x") + v = strings.TrimSuffix(v, ".*") + v = strings.TrimSuffix(v, ".x") + + parts := strings.Split(v, ".") + if len(parts) >= 3 { + return nil, fmt.Errorf("not a partial version: %s", v) + } + + // All parts must be plain non-negative integers (no pre-release or build metadata). + nums := make([]uint64, len(parts)) + for i, p := range parts { + n, err := strconv.ParseUint(p, 10, 64) + if err != nil { + return nil, fmt.Errorf("not a partial version: %s", v) + } + nums[i] = n + } + + var constraintStr string + switch len(nums) { + case 1: // e.g. "3" → >= 3.0.0, < 4.0.0 + constraintStr = fmt.Sprintf(">= %d.0.0, < %d.0.0", nums[0], nums[0]+1) + case 2: // e.g. "3.1" → >= 3.1.0, < 3.2.0 + constraintStr = fmt.Sprintf(">= %d.%d.0, < %d.%d.0", nums[0], nums[1], nums[0], nums[1]+1) + default: + return nil, fmt.Errorf("not a partial version: %s", v) + } + + c, err := semver.NewConstraint(constraintStr) + if err != nil { + return nil, fmt.Errorf("build constraint: %w", err) + } + return c, nil +} diff --git a/cmd/migrate_test.go b/cmd/migrate_test.go index 0354c68..743d4f0 100644 --- a/cmd/migrate_test.go +++ b/cmd/migrate_test.go @@ -9,6 +9,7 @@ import ( "path/filepath" "testing" + "github.com/Masterminds/semver/v3" "github.com/jarcoal/httpmock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -201,40 +202,99 @@ func main() { } func Test_Migrate_TargetVersionShort(t *testing.T) { - dir, err := os.MkdirTemp("", "migrate_short_version") - require.NoError(t, err) - defer func() { require.NoError(t, os.RemoveAll(dir)) }() - - require.NoError(t, os.WriteFile(filepath.Join(dir, "go.mod"), []byte(goModV2), 0o600)) + releases := `[{"tag_name":"v3.1.2","prerelease":false,"draft":false},{"tag_name":"v3.1.0","prerelease":false,"draft":false},{"tag_name":"v3.0.0","prerelease":false,"draft":false}]` + + tests := []struct { + flag string + expectedVersion string + }{ + {flag: "-t=3", expectedVersion: "3.1.2"}, // major only + {flag: "-t=v3", expectedVersion: "3.1.2"}, // major with v prefix + {flag: "-t=3.*", expectedVersion: "3.1.2"}, // wildcard minor + {flag: "-t=3.x", expectedVersion: "3.1.2"}, // x wildcard minor + {flag: "-t=3.1", expectedVersion: "3.1.2"}, // major.minor + {flag: "-t=3.1.*", expectedVersion: "3.1.2"}, // wildcard patch + {flag: "-t=3.1.x", expectedVersion: "3.1.2"}, // x wildcard patch + {flag: "-t=3.0", expectedVersion: "3.0.0"}, // major.minor resolves to 3.0.x latest + {flag: "-t=3.0.0", expectedVersion: "3.0.0"}, // full version, direct use + } main := `package main import "github.com/gofiber/fiber/v2" func main() { _ = fiber.New() }` - require.NoError(t, os.WriteFile(filepath.Join(dir, "main.go"), []byte(main), 0o600)) - cwd, err := os.Getwd() - require.NoError(t, err) - require.NoError(t, os.Chdir(dir)) - defer func() { require.NoError(t, os.Chdir(cwd)) }() - - httpmock.Activate() - defer httpmock.DeactivateAndReset() - clearHTTPCache() - httpmock.RegisterResponder(http.MethodGet, "https://api.github.com/repos/gofiber/fiber/releases?per_page=100", - httpmock.NewBytesResponder(200, []byte(`[{"tag_name":"v3.1.0","prerelease":false,"draft":false},{"tag_name":"v3.0.0","prerelease":false,"draft":false}]`))) - - cmd := newMigrateCmd() - setupCmd() - defer teardownCmd() - out, err := runCobraCmd(cmd, "-t=3") - require.NoError(t, err) - - assert.Contains(t, out, "Migration from Fiber 2.0.6 to 3.1.0") + for _, tt := range tests { + t.Run(tt.flag, func(t *testing.T) { + dir, err := os.MkdirTemp("", "migrate_short_version") + require.NoError(t, err) + defer func() { require.NoError(t, os.RemoveAll(dir)) }() + + require.NoError(t, os.WriteFile(filepath.Join(dir, "go.mod"), []byte(goModV2), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "main.go"), []byte(main), 0o600)) + + cwd, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, os.Chdir(dir)) + defer func() { require.NoError(t, os.Chdir(cwd)) }() + + httpmock.Activate() + defer httpmock.DeactivateAndReset() + clearHTTPCache() + httpmock.RegisterResponder(http.MethodGet, "https://api.github.com/repos/gofiber/fiber/releases?per_page=100", + httpmock.NewBytesResponder(200, []byte(releases))) + + cmd := newMigrateCmd() + setupCmd() + defer teardownCmd() + out, err := runCobraCmd(cmd, tt.flag) + require.NoError(t, err) + + assert.Contains(t, out, "Migration from Fiber 2.0.6 to "+tt.expectedVersion) + content := readFileTB(t, filepath.Join(dir, "go.mod")) + assert.Contains(t, content, "github.com/gofiber/fiber/v3 v"+tt.expectedVersion) + }) + } +} - content := readFileTB(t, filepath.Join(dir, "go.mod")) - assert.Contains(t, content, "github.com/gofiber/fiber/v3 v3.1.0") +func Test_PartialVersionConstraint(t *testing.T) { + tests := []struct { + checks []string // versions that should match + input string + rejects []string // versions that should not match + wantErr bool + }{ + {input: "3", checks: []string{"3.0.0", "3.1.0", "3.1.2"}, rejects: []string{"2.9.9", "4.0.0"}}, + {input: "3.*", checks: []string{"3.0.0", "3.1.2"}, rejects: []string{"2.0.0", "4.0.0"}}, + {input: "3.x", checks: []string{"3.0.0", "3.9.9"}, rejects: []string{"2.0.0", "4.0.0"}}, + {input: "3.*.*", checks: []string{"3.0.0", "3.1.2"}, rejects: []string{"2.0.0", "4.0.0"}}, + {input: "3.x.x", checks: []string{"3.0.0", "3.1.2"}, rejects: []string{"2.0.0", "4.0.0"}}, + {input: "3.1", checks: []string{"3.1.0", "3.1.9"}, rejects: []string{"3.0.9", "3.2.0"}}, + {input: "3.1.*", checks: []string{"3.1.0", "3.1.9"}, rejects: []string{"3.0.9", "3.2.0"}}, + {input: "3.1.x", checks: []string{"3.1.0", "3.1.9"}, rejects: []string{"3.0.9", "3.2.0"}}, + {input: "3.0.0", wantErr: true}, // full version → not partial + {input: "3.0.0-beta.1", wantErr: true}, // pre-release → not partial + {input: "abc", wantErr: true}, // invalid + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + c, err := partialVersionConstraint(tt.input) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + for _, v := range tt.checks { + sv := semver.MustParse(v) + assert.Truef(t, c.Check(sv), "expected %q to satisfy constraint for %q", v, tt.input) + } + for _, v := range tt.rejects { + sv := semver.MustParse(v) + assert.Falsef(t, c.Check(sv), "expected %q NOT to satisfy constraint for %q", v, tt.input) + } + }) + } } func Test_RunGoMod(t *testing.T) { diff --git a/cmd/version.go b/cmd/version.go index e910fcb..a9b199a 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -12,6 +12,7 @@ import ( "strings" "time" + "github.com/Masterminds/semver/v3" "github.com/spf13/cobra" ) @@ -82,8 +83,8 @@ func LatestCliVersion() (string, error) { return latestVersionByURL("https://api.github.com/repos/gofiber/cli/releases/latest") } -// LatestFiberVersionForMajor retrieves the most recent non-prerelease Fiber release for a given major version. -func LatestFiberVersionForMajor(major uint64) (string, error) { +// LatestFiberVersionForConstraint retrieves the most recent non-prerelease Fiber release matching the given semver constraint. +func LatestFiberVersionForConstraint(constraint *semver.Constraints) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -109,17 +110,20 @@ func LatestFiberVersionForMajor(major uint64) (string, error) { return "", fmt.Errorf("decode response: %w", err) } - prefix := fmt.Sprintf("v%d.", major) for _, r := range releases { if r.Draft || r.Prerelease { continue } - if strings.HasPrefix(r.TagName, prefix) { + v, parseErr := semver.NewVersion(r.TagName) + if parseErr != nil { + continue + } + if constraint.Check(v) { return strings.TrimPrefix(r.TagName, "v"), nil } } - return "", fmt.Errorf("no release found for major version %d", major) + return "", errors.New("no matching release found") } func latestVersionByURL(url string) (string, error) { From 2d4ad55b1c3b19b86f0911c702ce3422192154b1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 06:34:06 +0000 Subject: [PATCH 4/4] Fix: paginate releases API and skip partial resolution when --hash is set Co-authored-by: ReneWerner87 <7063188+ReneWerner87@users.noreply.github.com> --- cmd/migrate.go | 15 +++++++----- cmd/migrate_test.go | 4 ++- cmd/version.go | 59 ++++++++++++++++++++++++++------------------- 3 files changed, 46 insertions(+), 32 deletions(-) diff --git a/cmd/migrate.go b/cmd/migrate.go index c046d25..1345d28 100644 --- a/cmd/migrate.go +++ b/cmd/migrate.go @@ -108,13 +108,16 @@ func migrateRunE(cmd *cobra.Command, opts MigrateOptions) error { } opts.TargetVersionS = strings.TrimPrefix(opts.TargetVersionS, "v") // Resolve partial/wildcard versions (e.g. "3", "3.*", "3.1", "3.1.x") to the - // latest matching release from GitHub. - if constraint, parseErr := partialVersionConstraint(opts.TargetVersionS); parseErr == nil { - resolved, resolveErr := LatestFiberVersionForConstraint(constraint) - if resolveErr != nil { - return fmt.Errorf("failed to resolve version %q: %w", opts.TargetVersionS, resolveErr) + // latest matching release from GitHub. Skip when a target hash is provided so + // the pseudo-version base is derived directly from the user-specified version. + if opts.TargetHash == "" { + if constraint, parseErr := partialVersionConstraint(opts.TargetVersionS); parseErr == nil { + resolved, resolveErr := LatestFiberVersionForConstraint(constraint) + if resolveErr != nil { + return fmt.Errorf("failed to resolve version %q: %w", opts.TargetVersionS, resolveErr) + } + opts.TargetVersionS = resolved } - opts.TargetVersionS = resolved } baseVersion, err := semver.NewVersion(opts.TargetVersionS) if err != nil { diff --git a/cmd/migrate_test.go b/cmd/migrate_test.go index 743d4f0..8fad2be 100644 --- a/cmd/migrate_test.go +++ b/cmd/migrate_test.go @@ -242,8 +242,10 @@ func main() { httpmock.Activate() defer httpmock.DeactivateAndReset() clearHTTPCache() - httpmock.RegisterResponder(http.MethodGet, "https://api.github.com/repos/gofiber/fiber/releases?per_page=100", + httpmock.RegisterResponder(http.MethodGet, "https://api.github.com/repos/gofiber/fiber/releases?per_page=100&page=1", httpmock.NewBytesResponder(200, []byte(releases))) + httpmock.RegisterResponder(http.MethodGet, "https://api.github.com/repos/gofiber/fiber/releases?per_page=100&page=2", + httpmock.NewBytesResponder(200, []byte(`[]`))) cmd := newMigrateCmd() setupCmd() diff --git a/cmd/version.go b/cmd/version.go index a9b199a..cdccb7d 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -88,38 +88,47 @@ func LatestFiberVersionForConstraint(constraint *semver.Constraints) (string, er ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - url := "https://api.github.com/repos/gofiber/fiber/releases?per_page=100" - b, status, err := cachedGET(ctx, url, nil) - if err != nil { - return "", fmt.Errorf("http request failed: %w", err) - } - if status != http.StatusOK { - msg := strings.TrimSpace(string(b)) - if msg == "" { - msg = http.StatusText(status) - } - return "", fmt.Errorf("http request failed: %s", msg) - } - - var releases []struct { + type release struct { TagName string `json:"tag_name"` Prerelease bool `json:"prerelease"` Draft bool `json:"draft"` } - if err := json.Unmarshal(b, &releases); err != nil { - return "", fmt.Errorf("decode response: %w", err) - } - for _, r := range releases { - if r.Draft || r.Prerelease { - continue + for page := 1; page <= 10; page++ { + url := fmt.Sprintf("https://api.github.com/repos/gofiber/fiber/releases?per_page=100&page=%d", page) + b, status, err := cachedGET(ctx, url, nil) + if err != nil { + return "", fmt.Errorf("http request failed: %w", err) } - v, parseErr := semver.NewVersion(r.TagName) - if parseErr != nil { - continue + if status != http.StatusOK { + msg := strings.TrimSpace(string(b)) + if msg == "" { + msg = http.StatusText(status) + } + return "", fmt.Errorf("http request failed: %s", msg) } - if constraint.Check(v) { - return strings.TrimPrefix(r.TagName, "v"), nil + + var releases []release + if err := json.Unmarshal(b, &releases); err != nil { + return "", fmt.Errorf("decode response: %w", err) + } + + // No more releases; all pages exhausted. + if len(releases) == 0 { + break + } + + for _, r := range releases { + if r.Draft || r.Prerelease { + continue + } + v, parseErr := semver.NewVersion(r.TagName) + if parseErr != nil { + continue + } + if constraint.Check(v) { + return strings.TrimPrefix(r.TagName, "v"), nil + } } }