Skip to content

Commit

Permalink
feat: Check for new major versions (#28)
Browse files Browse the repository at this point in the history
  • Loading branch information
nieomylnieja authored Feb 7, 2024
1 parent fe43716 commit be4b7c7
Show file tree
Hide file tree
Showing 37 changed files with 2,200 additions and 288 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,5 +39,5 @@ jobs:
run: make check/lint
- name: Run Gosec Security Scanner
run: make check/gosec
- name: Run unit tests
- name: Run tests
run: make test
57 changes: 49 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,16 +102,17 @@ Example:

### Results manipulation

| Flag | Explanation |
|----------------|------------------------------------------------------------|
| `--releases` | Count number of releases between current and latest. |
| `--versions` | Calculate version number delta between current and latest. |
| `--indirect` | Include indirect dependencies in the results. |
| `--skip-fresh` | Skip up-to-date dependencies from the results. |
<!-- markdownlint-disable MD013 -->

### Module sources
| Flag | Explanation |
|-----------------------|--------------------------------------------------------------|
| `--releases` | Count number of releases between current and latest. |
| `--versions` | Calculate version number delta between current and latest. |
| `--indirect` | Include indirect dependencies in the results. |
| `--skip-fresh` | Skip up-to-date dependencies from the results. |
| `--find-latest-major` | Use next, greater than or equal to v2 version as the latest. |

<!-- markdownlint-disable MD013 -->
### Module sources

| Source | Flag | Example |
|-------------|-----------|-----------------------------------------------------------------------|
Expand Down Expand Up @@ -140,6 +141,46 @@ flags:
| `--cache` | Enable caching. |
| `--cache-file-path` | Use the specified file fro caching. |

## Go versioning

By default `go-libyear` will fetch the latest version for the current major
version adhering to the following rules:

- If the current major version is equal to 0.x.x and there's version 1.x.x
available, set the latest to 1.x.x.
- If the current major is equal to or greater than 1.x.x, set the latest
to 1.x.x.

If you wish to always set the next major version as the latest, you can use
the `--find-latest-major` (short `-M`) flag.
This flag enforces the following rules:

- If the current major is equal to or greater than x.x.x, set the latest
to the latest (by semver) available version.
- If the latest major version is greater than the current major and the
current version has been published after the first version of the latest
major, the [libyear](#libyear) is calculated as a difference between the
first version of the latest major and latest major version.

Example:
> Current version is 1.21.9 (2024-02-01), latest is 2.0.5 (2024-01-19); \
1.21.9 was a security fix, it still means we're some time behind v2; \
2.0.0 was released on 2024-01-02, this means we're 17 days
(2024-01-19 - 2024-01-02) behind the latest v2, despite the fact that we've
updated to the latest security patch for v1.

If you wish to not compensate for such cases and leave libyear as is
(it won't be ever negative, we round it to 0 if negative), use the
`--no-libyear-compensation` flag.

The [modules reference](https://go.dev/ref/mod) states that:
> If the module is released at major version 2 or higher,
the module path must end with a major version suffix like /v2.

This is however not always the case, some older projects, usually pre-module,
might not adhere to that.
The aforementioned flag also works with such scenarios.

## Caveats

### Accessing private repositories
Expand Down
42 changes: 30 additions & 12 deletions cmd/go-libyear/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,13 @@ const (
)

var flagToOption = map[string]golibyear.Option{
flagIndirect.Name: golibyear.OptionIncludeIndirect,
flagSkipFresh.Name: golibyear.OptionSkipFresh,
flagReleases.Name: golibyear.OptionShowReleases,
flagVersions.Name: golibyear.OptionShowVersions,
flagUseGoList.Name: golibyear.OptionUseGoList,
flagIndirect.Name: golibyear.OptionIncludeIndirect,
flagSkipFresh.Name: golibyear.OptionSkipFresh,
flagReleases.Name: golibyear.OptionShowReleases,
flagVersions.Name: golibyear.OptionShowVersions,
flagUseGoList.Name: golibyear.OptionUseGoList,
flagFindLatestMajor.Name: golibyear.OptionFindLatestMajor,
flagNoLibyearCompensation.Name: golibyear.OptionNoLibyearCompensation,
}

var (
Expand Down Expand Up @@ -57,12 +59,7 @@ var (
Usage: "Use custom cache file path",
DefaultText: "$XDG_CACHE_HOME/go-libyear/modules or $HOME/.cache/go-libyear/modules",
Category: categoryCache,
Action: func(c *cli.Context, path cli.Path) error {
if !c.IsSet("cache") {
return errors.Errorf("--cache-file-path flag can only be used in conjunction with --cache")
}
return nil
},
Action: useOnlyWith[cli.Path]("cache-file-path", flagCache.Name),
}
flagTimeout = &cli.DurationFlag{
Name: "timeout",
Expand Down Expand Up @@ -95,14 +92,35 @@ var (
Usage: "Display the number of major, minor, and patch versions between current and newest versions",
Category: categoryOutput,
}
flagFindLatestMajor = &cli.BoolFlag{
Name: "find-latest-major",
Aliases: []string{"M"},
Usage: "Use next, greater than or equal to v2 version as the latest",
}
flagNoLibyearCompensation = &cli.BoolFlag{
Name: "no-libyear-compensation",
Usage: "Do not compensate for negative or zero libyear " +
"values if latest version was published before current version",
Action: useOnlyWith[bool]("no-libyear-compensation", flagFindLatestMajor.Name),
}
flagVersion = &cli.BoolFlag{
Name: "version",
Aliases: []string{"v"},
Usage: "Show the program version",
Action: func(context *cli.Context, b bool) error {
Action: func(_ *cli.Context, _ bool) error {
fmt.Printf("Version: %s\nGitTag: %s\nBuildDate: %s\n",
BuildVersion, BuildGitTag, BuildDate)
return nil
},
}
)

// useOnlyWith creates an action which will verify if this flag was used with the dependent flag.
func useOnlyWith[T any](this, dependent string) func(*cli.Context, T) error {
return func(ctx *cli.Context, _ T) error {
if !ctx.IsSet(dependent) {
return errors.Errorf("--%s flag can only be used in conjunction with --%s", this, dependent)
}
return nil
}
}
4 changes: 4 additions & 0 deletions cmd/go-libyear/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
_ "embed"
"fmt"
"log"
"net/http"
"os"
"os/signal"
Expand All @@ -28,6 +29,7 @@ var (
var usageText string

func main() {
log.SetOutput(os.Stderr)
app := &cli.App{
Usage: "Calculate Go module's libyear!",
UsageText: usageText,
Expand All @@ -47,6 +49,8 @@ func main() {
flagReleases,
flagVersions,
flagVersion,
flagFindLatestMajor,
flagNoLibyearCompensation,
},
Suggest: true,
}
Expand Down
150 changes: 125 additions & 25 deletions command.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,30 +2,36 @@ package libyear

import (
"context"
"errors"
"log"
"os"
pathlib "path"
"slices"
"sort"
"strconv"
"strings"
"time"

"github.com/nieomylnieja/go-libyear/internal"

"github.com/Masterminds/semver"
"github.com/pkg/errors"
"golang.org/x/sync/errgroup"
)

type Option int

const (
OptionShowReleases Option = 1 << iota // 1
OptionShowVersions // 2
OptionSkipFresh // 4
OptionIncludeIndirect // 8
OptionUseGoList
OptionShowReleases Option = 1 << iota // 1
OptionShowVersions // 2
OptionSkipFresh // 4
OptionIncludeIndirect // 8
OptionUseGoList // 16
OptionFindLatestMajor // 32
OptionNoLibyearCompensation // 32
)

//go:generate mockgen -destination internal/mocks/mocks.go -package mocks -typed . ModulesRepo,VersionsGetter

type ModulesRepo interface {
VersionsGetter
GetModFile(path string, version *semver.Version) ([]byte, error)
Expand Down Expand Up @@ -97,7 +103,7 @@ func (c Command) runForModule(module *internal.Module) error {
module.Skipped = true

// Fetch latest.
latest, err := c.repo.GetLatestInfo(module.Path)
latest, err := c.getLatestInfo(module.Path)
if err != nil {
return err
}
Expand All @@ -118,10 +124,26 @@ func (c Command) runForModule(module *internal.Module) error {
module.Time = fetchedModule.Time
}

currentTime := module.Time
if c.optionIsSet(OptionFindLatestMajor) &&
!c.optionIsSet(OptionNoLibyearCompensation) &&
module.Path != latest.Path {
first, err := c.findFirstModule(latest.Path)
if err != nil {
return err
}
if module.Time.After(first.Time) {
log.Printf("INFO: current module version %s is newer than latest version %s; "+
"libyear will be calculated from the first version of latest major (%s) to the latest version (%s); "+
"if you wish to disable this behavior, use --allow-negative-libyear flag",
module.Version, latest.Version, first.Version, module.Version)
currentTime = first.Time
}
}
// The following calculations are based on https://ericbouwers.github.io/papers/icse15.pdf.
module.Libyear = calculateLibyear(module, latest)
module.Libyear = calculateLibyear(currentTime, latest.Time)
if c.optionIsSet(OptionShowReleases) {
versions, err := c.getVersions(module)
versions, err := c.getAllVersions(latest)
if err == errNoVersions {
log.Printf("WARN: module '%s' does not have any versions", module.Path)
return nil
Expand All @@ -138,34 +160,112 @@ func (c Command) runForModule(module *internal.Module) error {

var errNoVersions = errors.New("no versions found")

func (c Command) getVersions(module *internal.Module) ([]*semver.Version, error) {
versions, err := c.repo.GetVersions(module.Path)
func (c Command) getAllVersions(latest *internal.Module) ([]*semver.Version, error) {
allVersions := make([]*semver.Version, 0)
for _, path := range latest.AllPaths {
versions, err := c.getVersionsForPath(path, latest.Version.Prerelease() != "")
if err != nil {
return nil, err
}
allVersions = append(allVersions, versions...)
}
sort.Sort(semver.Collection(allVersions))
return allVersions, nil
}

func (c Command) getVersionsForPath(path string, isPrerelease bool) ([]*semver.Version, error) {
versions, err := c.repo.GetVersions(path)
if err != nil {
return nil, err
}
if len(versions) > 0 {
return versions, nil
}
if !isPrerelease {
return nil, errNoVersions
}
// Try fetching the versions from deps.dev.
// Go list does not list prerelease versions, which is fine,
// unless we're dealing with a prerelease version ourselves.
versions, err = c.fallbackVersions.GetVersions(path)
if err != nil {
return nil, err
}
// Check again.
if len(versions) == 0 {
if module.Version.Prerelease() == "" {
return nil, errNoVersions
}
// Try fetching the versions from deps.dev.
// Go list does not list prerelease versions, which is fine,
// unless we're dealing with a prerelease version ourselves.
versions, err = c.fallbackVersions.GetVersions(module.Path)
return nil, errNoVersions
}
return versions, nil
}

func (c Command) getLatestInfo(path string) (*internal.Module, error) {
var paths []string
var latest *internal.Module
for {
lts, err := c.repo.GetLatestInfo(path)
if err != nil {
if strings.Contains(err.Error(), "no matching versions") {
break
}
return nil, err
}
// Check again.
if len(versions) == 0 {
return nil, errNoVersions
// In case for whatever reason we start endlessly looping here, break it.
if latest != nil && latest.Version.Compare(lts.Version) == 0 {
return latest, nil
}
latest = lts
if !c.optionIsSet(OptionFindLatestMajor) {
break
}
// Increment major version.
var newMajor int64
if latest.Version.Major() > 1 {
newMajor = latest.Version.Major() + 1
} else {
newMajor = 2
}
paths = append(paths, path)
path = updatePathVersion(path, latest.Version.Major(), newMajor)
}
// In case we don't have v2 or above.
if len(paths) == 0 {
paths = append(paths, latest.Path)
}
latest.AllPaths = paths
return latest, nil
}

// findFirstModule finds the first module in the given path.
// If the path has /v2 or higher suffix it will find the first module in this version.
func (c Command) findFirstModule(path string) (*internal.Module, error) {
versions, err := c.repo.GetVersions(path)
if err != nil {
return nil, err
}
if len(versions) == 0 {
return nil, errors.Errorf("no versions found for path %s, expected at least one", path)
}
sort.Sort(semver.Collection(versions))
return versions, nil
return c.repo.GetInfo(path, versions[0])
}

func calculateLibyear(module, latest *internal.Module) float64 {
diff := latest.Time.Sub(module.Time)
return diff.Seconds() / secondsInYear
func updatePathVersion(path string, currentMajor, newMajor int64) string {
if currentMajor > 1 {
// Only trim the suffix from post-modules version paths.
if strings.HasSuffix(path, strconv.Itoa(int(currentMajor))) {
path = pathlib.Dir(path)
}
}
return pathlib.Join(path, "v"+strconv.Itoa(int(newMajor)))
}

func calculateLibyear(moduleTime, latestTime time.Time) float64 {
diff := latestTime.Sub(moduleTime)
libyear := diff.Seconds() / secondsInYear
if libyear < 0 {
libyear = 0
}
return libyear
}

func calculateReleases(module, latest *internal.Module, versions []*semver.Version) int {
Expand Down
Loading

0 comments on commit be4b7c7

Please sign in to comment.