cmd/gorelease: infer base version if unspecified

If the -base flag is not set on the command line, it is now inferred
from the -version flag and available versions. If the -version flag is
given, the base version will be the highest available release version
lower than -version. Otherwise, the base version will be the highest
release version. Pre-release versions are not considered.

If there are no appropriate versions, and the release version is
unspecified or appears to be the first release (e.g., v0.1.0, v2.0.0),
the inferred base version will be "none" (meaning no comparison will
be made). Otherwise, an error will be shown.

Updates golang/go#26420

Change-Id: Iee45a4183f3e4a219c02a69b5d16a3cc5478644c
Reviewed-on: https://go-review.googlesource.com/c/exp/+/216078
Run-TryBot: Jay Conrod <jayconrod@google.com>
TryBot-Result: Gobot Gobot <gobot@golang.org>
Reviewed-by: Bryan C. Mills <bcmills@google.com>
diff --git a/cmd/gorelease/errors.go b/cmd/gorelease/errors.go
index 0ab2ffb..1e0ea28 100644
--- a/cmd/gorelease/errors.go
+++ b/cmd/gorelease/errors.go
@@ -5,6 +5,7 @@
 package main
 
 import (
+	"errors"
 	"flag"
 	"fmt"
 	"os/exec"
@@ -22,7 +23,7 @@
 	return &usageError{err: fmt.Errorf(format, args...)}
 }
 
-const usageText = `usage: gorelease -base=version [-version=version]`
+const usageText = `usage: gorelease [-base=version] [-version=version]`
 
 func (e *usageError) Error() string {
 	msg := ""
@@ -32,21 +33,43 @@
 	return usageText + "\n" + msg + "\nFor more information, run go doc golang.org/x/exp/cmd/gorelease"
 }
 
+type baseVersionError struct {
+	err error
+}
+
+func (e *baseVersionError) Error() string {
+	return fmt.Sprintf("could not find base version: %v", e.err)
+}
+
+func (e *baseVersionError) Unwrap() error {
+	return e.err
+}
+
 type downloadError struct {
 	m   module.Version
 	err error
 }
 
 func (e *downloadError) Error() string {
-	var msg string
-	if xerr, ok := e.err.(*exec.ExitError); ok {
-		msg = strings.TrimSpace(string(xerr.Stderr))
-	} else {
-		msg = e.err.Error()
-	}
+	msg := e.err.Error()
 	sep := " "
 	if strings.Contains(msg, "\n") {
 		sep = "\n"
 	}
 	return fmt.Sprintf("error downloading module %s@%s:%s%s", e.m.Path, e.m.Version, sep, msg)
 }
+
+// cleanCmdError simplifies error messages from os/exec.Cmd.Run.
+// For ExitErrors, it trims and returns stderr. This is useful for go commands
+// that print well-formatted errors. By default, ExitError prints the exit
+// status but not stderr.
+//
+// cleanCmdError returns other errors unmodified.
+func cleanCmdError(err error) error {
+	if xerr, ok := err.(*exec.ExitError); ok {
+		if stderr := strings.TrimSpace(string(xerr.Stderr)); stderr != "" {
+			return errors.New(stderr)
+		}
+	}
+	return err
+}
diff --git a/cmd/gorelease/gorelease.go b/cmd/gorelease/gorelease.go
index 90dda61..7c898a5 100644
--- a/cmd/gorelease/gorelease.go
+++ b/cmd/gorelease/gorelease.go
@@ -42,7 +42,8 @@
 // "v2.3.4") or "none". If the version is "none", gorelease will not compare the
 // current version against any previous version; it will only validate the
 // current version. This is useful for checking the first release of a new major
-// version.
+// version. If -base is not specified, gorelease will attempt to infer a base
+// version from the -version flag and available released versions.
 //
 // -version=version: The proposed version to be released. If specified,
 // gorelease will confirm whether this version is consistent with changes made
@@ -66,6 +67,7 @@
 	"os/exec"
 	"path"
 	"path/filepath"
+	"sort"
 	"strings"
 
 	"golang.org/x/mod/modfile"
@@ -102,12 +104,6 @@
 //   the APIs are still compatible, just with a different module split).
 
 // TODO(jayconrod):
-// * Automatically detect base version if unspecified.
-//   If -version is vX.Y.(Z+1), use vX.Y.Z (with a message if it doesn't exist)
-//   If -version is vX.(Y+1).0, use vX.Y.0
-//   If -version is vX.0.0, use none
-//   If -version is a prerelease, use same base as if it were a release.
-//   If -version is not set, use latest release version or none.
 // * Allow -base to be an arbitrary revision name that resolves to a version
 //   or pseudo-version.
 // * Don't accept -version that increments minor or patch version by more than 1
@@ -176,10 +172,7 @@
 	if len(fs.Args()) > 0 {
 		return false, usageErrorf("no arguments allowed")
 	}
-	if baseVersion == "" {
-		return false, usageErrorf("-base flag must be specified.\nUse -base=none if there is no previous version.")
-	}
-	if baseVersion != "none" {
+	if baseVersion != "" && baseVersion != "none" {
 		if c := semver.Canonical(baseVersion); c != baseVersion {
 			return false, usageErrorf("base version %q is not a canonical semantic version", baseVersion)
 		}
@@ -189,7 +182,7 @@
 			return false, usageErrorf("release version %q is not a canonical semantic version", releaseVersion)
 		}
 	}
-	if baseVersion != "none" && releaseVersion != "" {
+	if baseVersion != "" && baseVersion != "none" && releaseVersion != "" {
 		if cmp := semver.Compare(baseVersion, releaseVersion); cmp == 0 {
 			return false, usageErrorf("-base and -version must be different")
 		} else if cmp > 0 {
@@ -232,6 +225,8 @@
 // should be set to modRoot.
 //
 // baseVersion is a previously released version of the module to compare.
+// If baseVersion is "", a base version will be detected automatically, based
+// on releaseVersion or the latest available version of the module.
 // If baseVersion is "none", no comparison will be performed, and
 // the returned report will only describe problems with the release version.
 //
@@ -267,6 +262,12 @@
 		panic(fmt.Sprintf("could not find version suffix in module path %q", modPath))
 	}
 
+	baseVersionInferred := baseVersion == ""
+	if baseVersionInferred {
+		if baseVersion, err = inferBaseVersion(modPath, releaseVersion); err != nil {
+			return report{}, err
+		}
+	}
 	if baseVersion != "none" {
 		if err := module.Check(modPath, baseVersion); err != nil {
 			return report{}, fmt.Errorf("can't compare major versions: base version %s does not belong to module %s", baseVersion, modPath)
@@ -388,11 +389,12 @@
 		return false
 	}
 	r := report{
-		modulePath:     modPath,
-		baseVersion:    baseVersion,
-		releaseVersion: releaseVersion,
-		tagPrefix:      tagPrefix,
-		diagnostics:    diagnostics,
+		modulePath:          modPath,
+		baseVersion:         baseVersion,
+		baseVersionInferred: baseVersionInferred,
+		releaseVersion:      releaseVersion,
+		tagPrefix:           tagPrefix,
+		diagnostics:         diagnostics,
 	}
 	for _, pair := range zipPackages(basePkgs, releasePkgs) {
 		basePkg, releasePkg := pair.base, pair.release
@@ -512,6 +514,85 @@
 	return module.CheckPath(modPath)
 }
 
+// inferBaseVersion returns an appropriate base version if one was not
+// specified explicitly.
+//
+// If releaseVersion is not "", inferBaseVersion returns the highest available
+// release version of the module lower than releaseVersion.
+// Otherwise, inferBaseVersion returns the highest available release version.
+// Pre-release versions are not considered. If there is no available version,
+// and releaseVersion appears to be the first release version (for example,
+// "v0.1.0", "v2.0.0"), "none" is returned.
+func inferBaseVersion(modPath, releaseVersion string) (baseVersion string, err error) {
+	defer func() {
+		if err != nil {
+			err = &baseVersionError{err: err}
+		}
+	}()
+
+	versions, err := loadVersions(modPath)
+	if err != nil {
+		return "", err
+	}
+
+	for i := len(versions) - 1; i >= 0; i-- {
+		v := versions[i]
+		if semver.Prerelease(v) == "" &&
+			(releaseVersion == "" || semver.Compare(v, releaseVersion) < 0) {
+			return v, nil
+		}
+	}
+
+	if releaseVersion == "" || maybeFirstVersion(releaseVersion) {
+		return "none", nil
+	}
+	return "", fmt.Errorf("no versions found lower than %s", releaseVersion)
+}
+
+// loadVersions loads the list of versions for the given module using
+// 'go list -m -versions'. The returned versions are sorted in ascending
+// semver order.
+func loadVersions(modPath string) ([]string, error) {
+	tmpDir, err := ioutil.TempDir("", "")
+	if err != nil {
+		return nil, err
+	}
+	defer os.Remove(tmpDir)
+	cmd := exec.Command("go", "list", "-m", "-versions", "--", modPath)
+	cmd.Dir = tmpDir
+	cmd.Env = append(os.Environ(), "GO111MODULE=on")
+	out, err := cmd.Output()
+	if err != nil {
+		return nil, cleanCmdError(err)
+	}
+	versions := strings.Fields(string(out))
+	if len(versions) > 0 {
+		versions = versions[1:] // skip module path
+	}
+
+	// Sort versions defensively. 'go list -m -versions' should always returns
+	// a sorted list of versions, but it's fast and easy to sort them here, too.
+	sort.Slice(versions, func(i, j int) bool {
+		return semver.Compare(versions[i], versions[j]) < 0
+	})
+	return versions, nil
+}
+
+// maybeFirstVersion returns whether v appears to be the first version
+// of a module.
+func maybeFirstVersion(v string) bool {
+	major, minor, patch, _, _, err := parseVersion(v)
+	if err != nil {
+		return false
+	}
+	if major == "0" {
+		return minor == "0" && patch == "0" ||
+			minor == "0" && patch == "1" ||
+			minor == "1" && patch == "0"
+	}
+	return minor == "0" && patch == "0"
+}
+
 // dirMajorSuffix returns a major version suffix for a slash-separated path.
 // For example, for the path "foo/bar/v2", dirMajorSuffix would return "v2".
 // If no major version suffix is found, "" is returned.
@@ -625,7 +706,7 @@
 func downloadModule(m module.Version) (modRoot string, err error) {
 	defer func() {
 		if err != nil {
-			err = &downloadError{m: m, err: err}
+			err = &downloadError{m: m, err: cleanCmdError(err)}
 		}
 	}()
 
diff --git a/cmd/gorelease/report.go b/cmd/gorelease/report.go
index 30943db..e26f153 100644
--- a/cmd/gorelease/report.go
+++ b/cmd/gorelease/report.go
@@ -23,10 +23,14 @@
 	modulePath string
 
 	// baseVersion is the "old" version of the module to compare against.
-	// It may be empty if there is no base version (for example, if this is
-	// the first release).
+	// It may be "none" if there is no base version (for example, if this is
+	// the first release). It may not be "".
 	baseVersion string
 
+	// baseVersionInferred is true if the base version was determined
+	// automatically (not specified with -base).
+	baseVersionInferred bool
+
 	// releaseVersion is the version of the module to release, either
 	// proposed with -version or inferred with suggestVersion.
 	releaseVersion string
@@ -81,6 +85,10 @@
 		}
 	}
 
+	if r.baseVersionInferred {
+		fmt.Fprintf(buf, "Inferred base version: %s\n", r.baseVersion)
+	}
+
 	if len(r.diagnostics) > 0 {
 		for _, d := range r.diagnostics {
 			fmt.Fprintln(buf, d)
diff --git a/cmd/gorelease/testdata/basic/v1_autobase_suggest.test b/cmd/gorelease/testdata/basic/v1_autobase_suggest.test
new file mode 100644
index 0000000..aafc829
--- /dev/null
+++ b/cmd/gorelease/testdata/basic/v1_autobase_suggest.test
@@ -0,0 +1,5 @@
+mod=example.com/basic
+version=v0.1.2
+-- want --
+Inferred base version: v1.1.2
+Suggested version: v1.1.3
diff --git a/cmd/gorelease/testdata/basic/v1_autobase_verify.test b/cmd/gorelease/testdata/basic/v1_autobase_verify.test
new file mode 100644
index 0000000..58cab03
--- /dev/null
+++ b/cmd/gorelease/testdata/basic/v1_autobase_verify.test
@@ -0,0 +1,6 @@
+mod=example.com/basic
+version=v1.0.1
+release=v1.0.2
+-- want --
+Inferred base version: v1.0.1
+v1.0.2 is a valid semantic version for this release.
diff --git a/cmd/gorelease/testdata/basic/v3_autobase_suggest.test b/cmd/gorelease/testdata/basic/v3_autobase_suggest.test
new file mode 100644
index 0000000..69fd61f
--- /dev/null
+++ b/cmd/gorelease/testdata/basic/v3_autobase_suggest.test
@@ -0,0 +1,8 @@
+mod=example.com/basic/v3
+-- go.mod --
+module example.com/basic/v3
+
+go 1.13
+-- want --
+Inferred base version: none
+Suggested version: v3.0.0
diff --git a/cmd/gorelease/testdata/basic/v3_autobase_verify_error.test b/cmd/gorelease/testdata/basic/v3_autobase_verify_error.test
new file mode 100644
index 0000000..d9c9b6a
--- /dev/null
+++ b/cmd/gorelease/testdata/basic/v3_autobase_verify_error.test
@@ -0,0 +1,6 @@
+mod=example.com/basic/v3
+version=v3.0.0-ignore
+release=v3.1.0
+error=true
+-- want --
+could not find base version: no versions found lower than v3.1.0
diff --git a/cmd/gorelease/testdata/basic/v3_autobase_verify_first.test b/cmd/gorelease/testdata/basic/v3_autobase_verify_first.test
new file mode 100644
index 0000000..830b688
--- /dev/null
+++ b/cmd/gorelease/testdata/basic/v3_autobase_verify_first.test
@@ -0,0 +1,6 @@
+mod=example.com/basic/v3
+version=v3.0.0-ignore
+release=v3.0.0
+-- want --
+Inferred base version: none
+v3.0.0 is a valid semantic version for this release.
diff --git a/cmd/gorelease/testdata/errors/bad_base.test b/cmd/gorelease/testdata/errors/bad_base.test
index d03f493..6147929 100644
--- a/cmd/gorelease/testdata/errors/bad_base.test
+++ b/cmd/gorelease/testdata/errors/bad_base.test
@@ -3,6 +3,6 @@
 error=true
 
 -- want --
-usage: gorelease -base=version [-version=version]
+usage: gorelease [-base=version] [-version=version]
 base version "master" is not a canonical semantic version
 For more information, run go doc golang.org/x/exp/cmd/gorelease
diff --git a/cmd/gorelease/testdata/errors/bad_release.test b/cmd/gorelease/testdata/errors/bad_release.test
index 46868a2..6015532 100644
--- a/cmd/gorelease/testdata/errors/bad_release.test
+++ b/cmd/gorelease/testdata/errors/bad_release.test
@@ -4,6 +4,6 @@
 error=true
 
 -- want --
-usage: gorelease -base=version [-version=version]
+usage: gorelease [-base=version] [-version=version]
 release version "master" is not a canonical semantic version
 For more information, run go doc golang.org/x/exp/cmd/gorelease
diff --git a/cmd/gorelease/testdata/errors/base_higher.test b/cmd/gorelease/testdata/errors/base_higher.test
index e5cb36c..12d7bba 100644
--- a/cmd/gorelease/testdata/errors/base_higher.test
+++ b/cmd/gorelease/testdata/errors/base_higher.test
@@ -4,6 +4,6 @@
 error=true
 
 -- want --
-usage: gorelease -base=version [-version=version]
+usage: gorelease [-base=version] [-version=version]
 base version ("v0.2.0") must be lower than release version ("v0.1.0")
 For more information, run go doc golang.org/x/exp/cmd/gorelease
diff --git a/cmd/gorelease/testdata/errors/same_base_release.test b/cmd/gorelease/testdata/errors/same_base_release.test
index f249699..0d0a09d 100644
--- a/cmd/gorelease/testdata/errors/same_base_release.test
+++ b/cmd/gorelease/testdata/errors/same_base_release.test
@@ -4,6 +4,6 @@
 error=true
 
 -- want --
-usage: gorelease -base=version [-version=version]
+usage: gorelease [-base=version] [-version=version]
 -base and -version must be different
 For more information, run go doc golang.org/x/exp/cmd/gorelease
diff --git a/cmd/gorelease/testdata/mod/example.com_basic_v3_v3.0.0-ignore.txt b/cmd/gorelease/testdata/mod/example.com_basic_v3_v3.0.0-ignore.txt
new file mode 100644
index 0000000..8e3cecf
--- /dev/null
+++ b/cmd/gorelease/testdata/mod/example.com_basic_v3_v3.0.0-ignore.txt
@@ -0,0 +1,6 @@
+-- .info --
+{"Version":"v3.0.0-ignore"}
+-- go.mod --
+module example.com/basic/v3
+
+go 1.13
diff --git a/go.mod b/go.mod
index 65cda84..7e25677 100644
--- a/go.mod
+++ b/go.mod
@@ -9,6 +9,7 @@
 	golang.org/x/image v0.0.0-20190802002840-cff245a6509b
 	golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028
 	golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b
+	golang.org/x/sync v0.0.0-20190423024810-112230192c58
 	golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24
 	golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa
 	golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898
diff --git a/go.sum b/go.sum
index 065988e..098b056 100644
--- a/go.sum
+++ b/go.sum
@@ -18,6 +18,7 @@
 golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58 h1:8gQV6CLnAEikrhgkHFbMAEhagSSnXWGV915qUMm9mrU=
 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=