@@ -3,12 +3,14 @@ package release
33import (
44 "context"
55 "fmt"
6+ "slices"
67 "strings"
78 "time"
89
910 "github.com/go-viper/mapstructure/v2"
1011 "github.com/sirupsen/logrus"
1112 "github.com/updatecli/updatecli/pkg/plugins/resources/gitlab/client"
13+ "github.com/updatecli/updatecli/pkg/plugins/utils/age"
1214 "github.com/updatecli/updatecli/pkg/plugins/utils/redact"
1315 "github.com/updatecli/updatecli/pkg/plugins/utils/version"
1416 gitlab "gitlab.com/gitlab-org/api/client-go"
@@ -23,6 +25,11 @@ type Spec struct {
2325 Repository string `yaml:",omitempty" jsonschema:"required"`
2426 // [S] VersionFilter provides parameters to specify version pattern and its type like regex, semver, or just latest.
2527 VersionFilter version.Filter `yaml:",omitempty"`
28+ // [S] Age defines the minimum or maximum age of a release to be considered valid.
29+ // It accepts a duration string (e.g., "24h", "7d", "3w", "1y").
30+ // The age of a release is the date at which it was released, or the date at which
31+ // it was created for a release published without one.
32+ Age age.Spec `yaml:",omitempty"`
2633 // [T] Title defines the GitLab release title.
2734 Title string `yaml:",omitempty"`
2835 // [C][T] Tag defines the GitLab release tag.
@@ -100,18 +107,24 @@ func New(spec interface{}) (*Gitlab, error) {
100107 return & g , nil
101108}
102109
103- // Retrieve git tags from a remote GitLab repository
104- func (g * Gitlab ) SearchReleases () ([]string , error ) {
110+ // SearchReleases retrieves the release tags from a remote GitLab repository, keeping
111+ // only the ones released inside the provided age window. The returned tags are ordered
112+ // from the oldest to the most recent one, as expected by the version filters.
113+ func (g * Gitlab ) SearchReleases (releaseAge age.Spec ) ([]string , error ) {
105114
106115 ctx := context .Background ()
107116 // Timeout api query after 30sec
108117 ctx , cancel := context .WithTimeout (ctx , 30 * time .Second )
109118 defer cancel ()
110119
111120 results := []string {}
112- page := 0
121+ // Tracks whether the repository publishes releases at all, so that a running
122+ // cooldown isn't reported as a repository without any release.
123+ foundRelease := false
124+ // GitLab paginates from page 1, so starting anywhere else fetches the first page twice.
125+ page := int64 (1 )
113126 for {
114- opt := & gitlab.ListReleasesOptions {ListOptions : gitlab.ListOptions {Page : int64 ( page ) , PerPage : 30 }}
127+ opt := & gitlab.ListReleasesOptions {ListOptions : gitlab.ListOptions {Page : page , PerPage : 30 }}
115128
116129 releases , resp , err := g .client .Releases .ListReleases (
117130 g .getPID (),
@@ -127,22 +140,66 @@ func (g *Gitlab) SearchReleases() ([]string, error) {
127140 logrus .Debugf ("GitLab Api Response:\n %+v" , resp )
128141 }
129142
130- for i := len ( releases ) - 1 ; i >= 0 ; i -- {
131- if ! releases [ i ] .UpcomingRelease {
132- results = append ( results , releases [ i ]. TagName )
143+ for _ , release := range releases {
144+ if release .UpcomingRelease {
145+ continue
133146 }
147+ foundRelease = true
148+
149+ if ! releaseAge .IsZero () {
150+ date , ok := releaseDate (release )
151+ if ! ok {
152+ logrus .Debugf ("ignoring release %q, which carries no date, as the age filter cannot be applied to it" , release .TagName )
153+ continue
154+ }
155+ if ! releaseAge .Matches (date ) {
156+ logrus .Debugf ("ignoring release %q, dated %s, as outside of the age window" , release .TagName , date )
157+ continue
158+ }
159+ }
160+
161+ results = append (results , release .TagName )
134162 }
135163
136- // Means that we parsed all pages
137- if int64 ( page ) >= resp .NextPage {
164+ // GitLab reports no next page once the last one has been visited.
165+ if resp .NextPage == 0 {
138166 break
139167 }
140- page ++
168+ page = resp .NextPage
169+ }
170+
171+ /*
172+ The repository does publish releases but the age filter discarded every one of
173+ them, which means the release we would have returned is still cooling down.
174+ That's not a lookup failure, so the sentinel lets the caller skip rather than
175+ fail.
176+ */
177+ if ! releaseAge .IsZero () && foundRelease && len (results ) == 0 {
178+ return nil , fmt .Errorf ("%w for the GitLab releases of %s" , age .ErrNoVersionMatchingAge , g .getPID ())
141179 }
142180
181+ // GitLab returns the most recent release first, while the version filters expect
182+ // the oldest one first, such as when they pick the last element for the "latest"
183+ // kind. Reversing the whole list, rather than each page, keeps the releases ordered
184+ // across page boundaries too.
185+ slices .Reverse (results )
186+
143187 return results , nil
144188}
145189
190+ // releaseDate returns the date at which a release became public, and whether GitLab
191+ // reported one. The release date is left empty for a release created without one, so
192+ // the creation date is used as a fallback.
193+ func releaseDate (r * gitlab.Release ) (time.Time , bool ) {
194+ if r .ReleasedAt != nil && ! r .ReleasedAt .IsZero () {
195+ return * r .ReleasedAt , true
196+ }
197+ if r .CreatedAt != nil && ! r .CreatedAt .IsZero () {
198+ return * r .CreatedAt , true
199+ }
200+ return time.Time {}, false
201+ }
202+
146203func (s Spec ) Validate () error {
147204 gotError := false
148205 missingParameters := []string {}
@@ -157,6 +214,11 @@ func (s Spec) Validate() error {
157214 missingParameters = append (missingParameters , "repository" )
158215 }
159216
217+ if err := s .Age .Validate (); err != nil {
218+ gotError = true
219+ logrus .Errorln (err )
220+ }
221+
160222 if len (missingParameters ) > 0 {
161223 logrus .Errorf ("missing parameter(s) [%s]" , strings .Join (missingParameters , "," ))
162224 }
@@ -179,6 +241,7 @@ func (g *Gitlab) ReportConfig() interface{} {
179241 },
180242 Repository : g .spec .Repository ,
181243 VersionFilter : g .spec .VersionFilter ,
244+ Age : g .spec .Age ,
182245 Tag : g .spec .Tag ,
183246 }
184247}
0 commit comments