Signed-off-by: Vasiliy Tolstov <v.tolstov@unistack.org>
This commit is contained in:
2023-08-19 16:55:52 +03:00
parent 0e18a63f10
commit 59e28e5a86
18 changed files with 801 additions and 804 deletions

View File

@@ -0,0 +1,292 @@
package modules
import (
"bufio"
"bytes"
"fmt"
"io"
"net/http"
"os"
"path"
"strconv"
"strings"
"golang.org/x/mod/module"
"golang.org/x/mod/semver"
"golang.org/x/sync/errgroup"
)
// Module contains the module path and versions
type Module struct {
Path string
Versions []string
}
// MaxVersion returns the latest version.
// If there are no versions, the empty string is returned.
// Prefix can be used to filter the versions based on a prefix.
// If pre is false, pre-release versions will are excluded.
func (m *Module) MaxVersion(prefix string, pre bool) string {
var max string
for _, v := range m.Versions {
if !semver.IsValid(v) || !strings.HasPrefix(v, prefix) {
continue
}
if !pre && semver.Prerelease(v) != "" {
continue
}
max = MaxVersion(v, max)
}
return max
}
// IsNewerVersion returns true if newversion is greater than oldversion in terms of semver.
// If major is true, then newversion must be a major version ahead of oldversion to be considered newer.
func IsNewerVersion(oldversion, newversion string, major bool) bool {
if major {
return semver.Compare(semver.Major(oldversion), semver.Major(newversion)) < 0
}
return semver.Compare(oldversion, newversion) < 0
}
// MaxVersion returns the larger of two versions according to semantic version precedence.
// Incompatible versions are considered lower than non-incompatible ones.
// Invalid versions are considered lower than valid ones.
// If both versions are invalid, the empty string is returned.
func MaxVersion(v, w string) string {
// sort by validity
vValid := semver.IsValid(v)
wValid := semver.IsValid(w)
if !vValid && !wValid {
return ""
}
if vValid != wValid {
if vValid {
return v
}
return w
}
// sort by compatibility
vIncompatible := strings.HasSuffix(semver.Build(v), "+incompatible")
wIncompatible := strings.HasSuffix(semver.Build(w), "+incompatible")
if vIncompatible != wIncompatible {
if wIncompatible {
return v
}
return w
}
// sort by semver
if semver.Compare(v, w) == 1 {
return v
}
return w
}
// NextMajor returns the next major version after the provided version
func NextMajor(version string) (string, error) {
major, err := strconv.Atoi(strings.TrimPrefix(semver.Major(version), "v"))
if err != nil {
return "", err
}
major++
return fmt.Sprintf("v%d", major), nil
}
// WithMajorPath returns the module path for the provided version
func (m *Module) WithMajorPath(version string) string {
prefix := ModPrefix(m.Path)
return JoinPath(prefix, version, "")
}
// NextMajorPath returns the module path of the next major version
func (m *Module) NextMajorPath() (string, bool) {
latest := m.MaxVersion("", true)
if latest == "" {
return "", false
}
if semver.Major(latest) == "v0" {
return "", false
}
next, err := NextMajor(latest)
if err != nil {
return "", false
}
return m.WithMajorPath(next), true
}
// Query the module proxy for all versions of a module.
// If the module does not exist, the second return parameter will be false
// cached sets the Disable-Module-Fetch: true header
func Query(modpath string, cached bool) (*Module, bool, error) {
escaped, err := module.EscapePath(modpath)
if err != nil {
return nil, false, err
}
url := fmt.Sprintf("https://proxy.golang.org/%s/@v/list", escaped)
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, false, err
}
req.Header.Set("User-Agent", "GoMajor/1.0")
if cached {
req.Header.Set("Disable-Module-Fetch", "true")
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, false, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
body, _ := io.ReadAll(res.Body)
if res.StatusCode == http.StatusNotFound && bytes.HasPrefix(body, []byte("not found:")) {
return nil, false, nil
}
msg := string(body)
if msg == "" {
msg = res.Status
}
return nil, false, fmt.Errorf("proxy: %s", msg)
}
var mod Module
mod.Path = modpath
sc := bufio.NewScanner(res.Body)
for sc.Scan() {
mod.Versions = append(mod.Versions, sc.Text())
}
if err := sc.Err(); err != nil {
return nil, false, err
}
return &mod, true, nil
}
// Latest finds the latest major version of a module
// cached sets the Disable-Module-Fetch: true header
func Latest(modpath string, cached bool) (*Module, error) {
latest, ok, err := Query(modpath, cached)
if err != nil {
return nil, err
}
if !ok {
return nil, fmt.Errorf("module not found: %s", modpath)
}
for i := 0; i < 100; i++ {
nextpath, ok := latest.NextMajorPath()
if !ok {
return latest, nil
}
next, ok, err := Query(nextpath, cached)
if err != nil {
return nil, err
}
if !ok {
// handle the case where a project switched to modules
// without incrementing the major version
version := latest.MaxVersion("", true)
if semver.Build(version) == "+incompatible" {
nextpath = latest.WithMajorPath(semver.Major(version))
if nextpath != latest.Path {
next, ok, err = Query(nextpath, cached)
if err != nil {
return nil, err
}
}
}
}
if !ok {
return latest, nil
}
latest = next
}
return nil, fmt.Errorf("request limit exceeded")
}
// QueryPackage tries to find the module path for the provided package path
// it does so by repeatedly chopping off the last path element and trying to
// use it as a path.
func QueryPackage(pkgpath string, cached bool) (*Module, error) {
prefix := pkgpath
for prefix != "" {
if module.CheckPath(prefix) == nil {
mod, ok, err := Query(prefix, cached)
if err != nil {
return nil, err
}
if ok {
modprefix := ModPrefix(mod.Path)
if modpath, pkgdir, ok := SplitPath(modprefix, pkgpath); ok && modpath != mod.Path {
if major, ok := ModMajor(modpath); ok {
if v := mod.MaxVersion(major, false); v != "" {
spec := JoinPath(modprefix, "", pkgdir) + "@" + v
return nil, fmt.Errorf("%s doesn't support import versioning; use %s", major, spec)
}
return nil, fmt.Errorf("failed to find module for package: %s", pkgpath)
}
}
return mod, nil
}
}
remaining, last := path.Split(prefix)
if last == "" {
break
}
prefix = strings.TrimSuffix(remaining, "/")
}
return nil, fmt.Errorf("failed to find module for package: %s", pkgpath)
}
// Update reports a newer version of a module.
// The Err field will be set if an error occured.
type Update struct {
Err error
Module module.Version
Version string
}
// UpdateOptions specifies a set of modules to check for updates.
// The OnUpdate callback will be invoked with any updates found.
type UpdateOptions struct {
OnUpdate func(Update)
Modules []module.Version
Pre bool
Cached bool
Major bool
}
// Updates finds updates for a set of specified modules.
func Updates(opt UpdateOptions) {
ch := make(chan Update)
go func() {
defer close(ch)
private := os.Getenv("GOPRIVATE")
var group errgroup.Group
if opt.Cached {
group.SetLimit(3)
} else {
group.SetLimit(1)
}
for _, m := range opt.Modules {
m := m
if module.MatchPrefixPatterns(private, m.Path) {
continue
}
group.Go(func() error {
mod, err := Latest(m.Path, opt.Cached)
if err != nil {
ch <- Update{Module: m, Err: err}
return nil
}
v := mod.MaxVersion("", opt.Pre)
if IsNewerVersion(m.Version, v, opt.Major) {
ch <- Update{Module: m, Version: v}
}
return nil
})
}
_ = group.Wait()
}()
for u := range ch {
if opt.OnUpdate != nil {
opt.OnUpdate(u)
}
}
}

View File

@@ -0,0 +1,115 @@
package modules
import (
"fmt"
"os"
"path/filepath"
"strings"
"golang.org/x/mod/module"
"golang.org/x/mod/semver"
)
// ModPrefix returns the module path with no SIV
func ModPrefix(modpath string) string {
prefix, _, ok := module.SplitPathVersion(modpath)
if !ok {
prefix = modpath
}
return prefix
}
// ModMajor returns the major version in vN format
func ModMajor(modpath string) (string, bool) {
_, major, ok := module.SplitPathVersion(modpath)
if ok {
major = strings.TrimPrefix(major, "/")
major = strings.TrimPrefix(major, ".")
}
return major, ok
}
// SplitPath splits the package path into the module path and the package subdirectory.
// It requires the a module path prefix to figure this out.
func SplitPath(modprefix, pkgpath string) (modpath, pkgdir string, ok bool) {
if !strings.HasPrefix(pkgpath, modprefix) {
return "", "", false
}
modpathlen := len(modprefix)
if rest := pkgpath[modpathlen:]; len(rest) > 0 && rest[0] != '/' && rest[0] != '.' {
return "", "", false
}
if strings.HasPrefix(pkgpath[modpathlen:], "/") {
modpathlen++
}
if idx := strings.Index(pkgpath[modpathlen:], "/"); idx >= 0 {
modpathlen += idx
} else {
modpathlen = len(pkgpath)
}
modpath = modprefix
if major, ok := ModMajor(pkgpath[:modpathlen]); ok {
modpath = JoinPath(modprefix, major, "")
}
pkgdir = strings.TrimPrefix(pkgpath[len(modpath):], "/")
return modpath, pkgdir, true
}
// SplitSpec splits the path/to/package@query format strings
func SplitSpec(spec string) (path, query string) {
parts := strings.SplitN(spec, "@", 2)
if len(parts) == 2 {
path = parts[0]
query = parts[1]
} else {
path = spec
}
return
}
// JoinPath creates a full package path given a module prefix, version, and package directory.
func JoinPath(modprefix, version, pkgdir string) string {
version = strings.TrimPrefix(version, ".")
version = strings.TrimPrefix(version, "/")
major := semver.Major(version)
pkgpath := modprefix
switch {
case strings.HasPrefix(pkgpath, "gopkg.in"):
pkgpath += "." + major
case major != "" && major != "v0" && major != "v1" && !strings.Contains(version, "+incompatible"):
if !strings.HasSuffix(pkgpath, "/") {
pkgpath += "/"
}
pkgpath += major
}
if pkgdir != "" {
pkgpath += "/" + pkgdir
}
return pkgpath
}
// FindModFile recursively searches up the directory structure until it
// finds the go.mod, reaches the root of the directory tree, or encounters
// an error.
func FindModFile(dir string) (string, error) {
var err error
dir, err = filepath.Abs(dir)
if err != nil {
return "", err
}
for {
name := filepath.Join(dir, "go.mod")
_, err := os.Stat(name)
if err == nil {
return name, nil
}
if !os.IsNotExist(err) {
return "", err
}
parent := filepath.Dir(dir)
if parent == dir {
return "", fmt.Errorf("cannot find go.mod")
}
dir = parent
}
}