Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions cmd/migrate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
108 changes: 88 additions & 20 deletions cmd/migrate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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) {
Expand Down
54 changes: 54 additions & 0 deletions cmd/version.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cmd

import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
Expand All @@ -11,6 +12,7 @@ import (
"strings"
"time"

"github.com/Masterminds/semver/v3"
"github.com/spf13/cobra"
)

Expand Down Expand Up @@ -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()
Expand Down