diff --git a/cmd/migrate.go b/cmd/migrate.go index 1ab98b3..1345d28 100644 --- a/cmd/migrate.go +++ b/cmd/migrate.go @@ -107,6 +107,18 @@ 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. 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 + } + } baseVersion, err := semver.NewVersion(opts.TargetVersionS) if err != nil { return fmt.Errorf("invalid version for \"%s\": %w", opts.TargetVersionS, err) @@ -241,3 +253,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 bc1a1f8..8fad2be 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,34 +202,101 @@ 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)) }() - - 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") + 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&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() + 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.0.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 1447ca7..cdccb7d 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -2,6 +2,7 @@ package cmd import ( "context" + "encoding/json" "errors" "fmt" "net/http" @@ -11,6 +12,7 @@ import ( "strings" "time" + "github.com/Masterminds/semver/v3" "github.com/spf13/cobra" ) @@ -81,6 +83,58 @@ func LatestCliVersion() (string, error) { return latestVersionByURL("https://api.github.com/repos/gofiber/cli/releases/latest") } +// 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() + + type release struct { + TagName string `json:"tag_name"` + Prerelease bool `json:"prerelease"` + Draft bool `json:"draft"` + } + + 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) + } + 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 []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 + } + } + } + + return "", errors.New("no matching release found") +} + func latestVersionByURL(url string) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel()