Skip to content

Commit

Permalink
feat: Add option to limit the package date (#40)
Browse files Browse the repository at this point in the history
Add `--age-limit` flag to limit the latest package version designation.
  • Loading branch information
nieomylnieja authored Feb 18, 2024
1 parent e43759f commit af316ad
Show file tree
Hide file tree
Showing 9 changed files with 517 additions and 18 deletions.
18 changes: 17 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ Quoting and paraphrasing [libyear.com](https://libyear.com/):
> Libyear is a simple measure of software **dependency freshness**. \
> It is a single number telling you how **up-to-date** your dependencies
> are. \
> Example: pkg/errors _v0.8.1_ (January 2019) is **1 libyear** behind _v0.9.0_
> Example: pkg/errors _v0.8.1_ (June 2019) is **1 libyear** behind _v0.9.0_
> (June 2020).
Libyear is the default metric calculated by the program.
Expand Down Expand Up @@ -130,6 +130,22 @@ Example:
| JSON | `--json` |
| CSV | `--csv` |

### Historical data

In order to calculate the metrics in a given point in time,
use `--age-limit` flag.
Example:

```sh
go-libyear --age-limit 2022-10-01T12:00:00Z ./go.mod
```

The latest version for each package will be appointed as the latest version
of the package before or at the provided timestamp.

The flag works any other flag. If using a script to extract a history
of the calculated metrics, it is recommended to use `--cache` flag as well.

### Caching

`go-libyear` ships with a built-in caching mechanism.
Expand Down
8 changes: 8 additions & 0 deletions builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package libyear

import (
"path/filepath"
"time"

"github.com/nieomylnieja/go-libyear/internal"
)
Expand All @@ -22,6 +23,7 @@ type CommandBuilder struct {
cacheFilePath string
opts Option
vcsRegistry *VCSRegistry
ageLimit time.Time
}

func (b CommandBuilder) WithCache(cacheFilePath string) CommandBuilder {
Expand Down Expand Up @@ -52,6 +54,11 @@ func (b CommandBuilder) WithVCSRegistry(registry *VCSRegistry) CommandBuilder {
return b
}

func (b CommandBuilder) WithAgeLimit(limit time.Time) CommandBuilder {
b.ageLimit = limit
return b
}

func (b CommandBuilder) Build() (*Command, error) {
if b.repo == nil {
var err error
Expand Down Expand Up @@ -90,5 +97,6 @@ func (b CommandBuilder) Build() (*Command, error) {
fallbackVersions: b.fallback,
opts: b.opts,
vcs: b.vcsRegistry,
ageLimit: b.ageLimit,
}, nil
}
5 changes: 5 additions & 0 deletions cmd/go-libyear/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,11 @@ var (
"values if latest version was published before current version",
Action: useOnlyWith[bool]("no-libyear-compensation", flagFindLatestMajor.Name),
}
flagAgeLimit = &cli.TimestampFlag{
Name: "age-limit",
Layout: time.RFC3339,
Usage: "Only consider versions which were published before or at the specified date",
}
flagVersion = &cli.BoolFlag{
Name: "version",
Aliases: []string{"v"},
Expand Down
6 changes: 5 additions & 1 deletion cmd/go-libyear/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,10 @@ func main() {
flagSkipFresh,
flagReleases,
flagVersions,
flagVersion,
flagFindLatestMajor,
flagNoLibyearCompensation,
flagAgeLimit,
flagVersion,
},
Suggest: true,
}
Expand Down Expand Up @@ -110,6 +111,9 @@ func run(cliCtx *cli.Context) error {
registry := golibyear.NewVCSRegistry(flagVCSCacheDir.Get(cliCtx))
builder = builder.WithVCSRegistry(registry)
}
if cliCtx.IsSet(flagAgeLimit.Name) {
builder = builder.WithAgeLimit(*flagAgeLimit.Get(cliCtx))
}

cmd, err := builder.Build()
if err != nil {
Expand Down
101 changes: 86 additions & 15 deletions command.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ type Command struct {
fallbackVersions VersionsGetter
opts Option
vcs *VCSRegistry
ageLimit time.Time
}

func (c Command) Run(ctx context.Context) error {
Expand Down Expand Up @@ -113,8 +114,17 @@ func (c Command) runForModule(module *internal.Module) error {
}
}

// Since we're parsing the go.mod file directly, we might need to fetch the Module.Time.
if module.Time.IsZero() {
fetchedModule, err := repo.GetInfo(module.Path, module.Version)
if err != nil {
return err
}
module.Time = fetchedModule.Time
}

// Fetch latest.
latest, err := c.getLatestInfo(repo, module.Path)
latest, err := c.getLatestInfo(module, repo)
if err != nil {
return err
}
Expand All @@ -126,15 +136,6 @@ func (c Command) runForModule(module *internal.Module) error {
}
module.Latest = latest

// Since we're parsing the go.mod file directly, we might need to fetch the Module.Time.
if module.Time.IsZero() {
fetchedModule, err := repo.GetInfo(module.Path, module.Version)
if err != nil {
return err
}
module.Time = fetchedModule.Time
}

currentTime := module.Time
if c.optionIsSet(OptionFindLatestMajor) &&
!c.optionIsSet(OptionNoLibyearCompensation) &&
Expand Down Expand Up @@ -195,10 +196,15 @@ func (c Command) getVersionsForPath(repo ModulesRepo, path string, isPrerelease
if !isPrerelease {
return nil, errNoVersions
}
fallback := c.fallbackVersions
// Alternative is the fallback as na argument to the function, which makes it even more messy.
if _, ok := repo.(VCSHandler); ok {
fallback = repo
}
// 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)
versions, err = fallback.GetVersions(path)
if err != nil {
return nil, err
}
Expand All @@ -209,11 +215,27 @@ func (c Command) getVersionsForPath(repo ModulesRepo, path string, isPrerelease
return versions, nil
}

func (c Command) getLatestInfo(repo ModulesRepo, path string) (*internal.Module, error) {
var paths []string
var latest *internal.Module
func (c Command) getLatestInfo(current *internal.Module, repo ModulesRepo) (*internal.Module, error) {
var (
path = current.Path
paths []string
latest *internal.Module
)
for {
lts, err := repo.GetLatestInfo(path)
var (
lts *internal.Module
err error
)
if c.ageLimit.IsZero() {
lts, err = repo.GetLatestInfo(path)
} else {
// If this is the first iteration, optimize findLatestBefore by passing it the current version module.
if latest == nil {
lts, err = c.findLatestBefore(repo, path, current)
} else {
lts, err = c.findLatestBefore(repo, path, nil)
}
}
if err != nil {
if strings.Contains(err.Error(), "no matching versions") {
break
Expand Down Expand Up @@ -334,3 +356,52 @@ func (c Command) newErrGroup(ctx context.Context) (*errgroup.Group, context.Cont
func (c Command) optionIsSet(option Option) bool {
return c.opts&option != 0
}

var errNoMatchingVersions = errors.New("no matching versions")

// findLatestBefore uses binary search to find the latest module published before the given time.
// It is highly recommended to use cache when calling this function.
// current argument is optional, if it is provided, the function optimizes its search by skipping
// every version preceding current version.
func (c Command) findLatestBefore(repo ModulesRepo, path string, current *internal.Module) (*internal.Module, error) {
if current != nil && c.ageLimit.Before(current.Time) {
return nil, errors.Errorf("current module release time: %s is after the before flag value: %s",
current.Time.Format(time.DateOnly), c.ageLimit.Format(time.DateOnly))
}
// Make sure we handle prerelease versions as well.
isPrerelease := current != nil && current.Version.Prerelease() != ""
versions, err := c.getVersionsForPath(repo, path, isPrerelease)
if err != nil {
return nil, err
}
sort.Sort(semver.Collection(versions))
// Optimize the search if current was provided.
if current != nil {
currentIndex := slices.IndexFunc(versions, func(v *semver.Version) bool { return current.Version.Equal(v) })
versions = versions[currentIndex+1:]
}
start, end := 0, (len(versions) - 1)
latest := current
for start <= end {
mid := (start + end) / 2
lts, err := repo.GetInfo(path, versions[mid])
if err != nil {
return nil, err
}
if lts.Time.After(c.ageLimit) {
// Investigate the lower half of the range.
end = mid - 1
} else {
// Investigate the upper half of the range.
// If the potential latest (lts) is after current latest candidate, update latest.
if latest == nil || lts.Time.After(latest.Time) {
latest = lts
}
start = mid + 1
}
}
if latest == nil {
return nil, errNoMatchingVersions
}
return latest, nil
}
Loading

0 comments on commit af316ad

Please sign in to comment.