feat(gitlab/tag): add age support by olblak · Pull Request #10281 · updatecli/updatecli · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion e2e/updatecli.d/success.d/gitlab/tag.yaml
143 changes: 143 additions & 0 deletions pkg/plugins/resources/gitlab/tag/age_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
package tag

import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/updatecli/updatecli/pkg/plugins/utils/age"
gitlab "gitlab.com/gitlab-org/api/client-go"
)

// daysAgo formats a date n days in the past the way the GitLab API reports one.
func daysAgo(n int) string {
return time.Now().Add(-time.Duration(n) * 24 * time.Hour).Format(time.RFC3339)
}

// newTagServer serves a single page of tags, the most recent one first the way the
// GitLab API orders them, so that the age filtering is exercised without reaching it.
func newTagServer(t *testing.T, tags ...string) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, "[%s]", strings.Join(tags, ","))
}))
}

func tagJSON(name, created string) string {
return fmt.Sprintf(`{"name":%q,"created_at":%q,"commit":{"committed_date":%q}}`, name, created, created)
}

func newTagResource(t *testing.T, url string, tagAge age.Spec) *Gitlab {
t.Helper()
resource, err := New(map[string]interface{}{
"url": url,
"owner": "updatecli",
"repository": "updatecli",
"age": map[string]interface{}{"minimum": tagAge.Minimum, "maximum": tagAge.Maximum},
})
require.NoError(t, err)
return resource
}

func TestSearchTagsAge(t *testing.T) {
// The most recent tag comes first, as the GitLab API reports it.
tags := []string{
tagJSON("v3.0.0", daysAgo(1)),
tagJSON("v2.0.0", daysAgo(10)),
tagJSON("v1.0.0", daysAgo(30)),
}

tests := []struct {
name string
tagAge age.Spec
want []string
wantSkip bool
}{
{
name: "no age filter keeps every tag",
want: []string{"v1.0.0", "v2.0.0", "v3.0.0"},
},
{
name: "the most recent tag is still in cooldown",
tagAge: age.Spec{Minimum: "7d"},
want: []string{"v1.0.0", "v2.0.0"},
},
{
name: "tags created too long ago are discarded",
tagAge: age.Spec{Maximum: "20d"},
want: []string{"v2.0.0", "v3.0.0"},
},
{
name: "every tag still in cooldown reports a running cooldown",
tagAge: age.Spec{Minimum: "60d"},
wantSkip: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := newTagServer(t, tags...)
defer server.Close()

got, err := newTagResource(t, server.URL, tt.tagAge).SearchTags(tt.tagAge)

if tt.wantSkip {
require.Error(t, err)
assert.ErrorIs(t, err, age.ErrNoVersionMatchingAge)
return
}

require.NoError(t, err)
assert.Equal(t, tt.want, got)
})
}
}

func TestTagDate(t *testing.T) {
created := time.Now().Add(-24 * time.Hour)
committed := time.Now().Add(-48 * time.Hour)

tests := []struct {
name string
tag gitlab.Tag
want time.Time
wantOk bool
}{
{
name: "the creation date wins",
tag: gitlab.Tag{CreatedAt: &created, Commit: &gitlab.Commit{CommittedDate: &committed}},
want: created,
wantOk: true,
},
{
name: "the committer date is the fallback",
tag: gitlab.Tag{Commit: &gitlab.Commit{CommittedDate: &committed}},
want: committed,
wantOk: true,
},
{
name: "a tag without any date reports none",
tag: gitlab.Tag{},
},
{
name: "a commit without a date reports none",
tag: gitlab.Tag{Commit: &gitlab.Commit{}},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, ok := tagDate(&tt.tag)
assert.Equal(t, tt.wantOk, ok)
if tt.wantOk {
assert.Equal(t, tt.want, got)
}
})
}
}
5 changes: 4 additions & 1 deletion pkg/plugins/resources/gitlab/tag/condition.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,17 @@ import (

"github.com/sirupsen/logrus"
"github.com/updatecli/updatecli/pkg/core/pipeline/scm"
"github.com/updatecli/updatecli/pkg/plugins/utils/age"
)

func (g *Gitlab) Condition(_ context.Context, source string, scm scm.ScmHandler) (pass bool, message string, err error) {
if scm != nil {
logrus.Warningf("scm is not supported for the GitLab tag condition, ignoring")
}

tags, err := g.SearchTags()
// A condition checks whether a specific tag exists, so the age filter, which only
// narrows down which tag to pick, doesn't apply here.
tags, err := g.SearchTags(age.Spec{})
if err != nil {
return false, "", fmt.Errorf("looking for GitLab tags: %w", err)
}
Expand Down
73 changes: 67 additions & 6 deletions pkg/plugins/resources/gitlab/tag/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ package tag
import (
"context"
"fmt"
"slices"
"strings"
"time"

"github.com/go-viper/mapstructure/v2"
"github.com/sirupsen/logrus"
"github.com/updatecli/updatecli/pkg/plugins/resources/gitlab/client"
"github.com/updatecli/updatecli/pkg/plugins/utils/age"
"github.com/updatecli/updatecli/pkg/plugins/utils/version"
gitlab "gitlab.com/gitlab-org/api/client-go"
)
Expand All @@ -22,6 +24,11 @@ type Spec struct {
Repository string `yaml:",omitempty" jsonschema:"required"`
// [S][C] VersionFilter provides parameters to specify version pattern and its type like regex, semver, or just latest.
VersionFilter version.Filter `yaml:",omitempty"`
// [S] Age defines the minimum or maximum age of a tag to be considered valid.
// It accepts a duration string (e.g., "24h", "7d", "3w", "1y").
// The age of a tag is its creation date, which GitLab reports as the tagger date
// of an annotated tag and as the commit date of a lightweight one.
Age age.Spec `yaml:",omitempty"`
// [S] Tag defines the GitLab tag .
Tag string `yaml:",omitempty"`
}
Expand Down Expand Up @@ -82,20 +89,26 @@ func New(spec interface{}) (*Gitlab, error) {

}

// Retrieve git tags from a remote GitLab repository
func (g *Gitlab) SearchTags() (tags []string, err error) {
// SearchTags retrieves the tags of a remote GitLab repository, keeping only the ones
// created inside the provided age window. The returned tags are ordered from the
// oldest to the most recent one, as expected by the version filters.
func (g *Gitlab) SearchTags(tagAge age.Spec) (tags []string, err error) {

// Timeout api query after 30sec
ctx := context.Background()
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()

page := 0
// GitLab paginates from page 1, so starting anywhere else fetches the first page twice.
page := int64(1)
// Tracks whether the repository holds tags at all, so that a running cooldown
// isn't reported as a repository without any tag.
foundTag := false

// Query gitlab api until we visit all pages
for {

opt := &gitlab.ListTagsOptions{ListOptions: gitlab.ListOptions{Page: int64(page), PerPage: 100}}
opt := &gitlab.ListTagsOptions{ListOptions: gitlab.ListOptions{Page: page, PerPage: 100}}

references, resp, err := g.client.Tags.ListTags(
g.getPID(),
Expand All @@ -112,18 +125,60 @@ func (g *Gitlab) SearchTags() (tags []string, err error) {
}

for _, ref := range references {
foundTag = true

if !tagAge.IsZero() {
date, ok := tagDate(ref)
if !ok {
logrus.Debugf("ignoring tag %q, which carries no date, as the age filter cannot be applied to it", ref.Name)
continue
}
if !tagAge.Matches(date) {
logrus.Debugf("ignoring tag %q, dated %s, as outside of the age window", ref.Name, date)
continue
}
}

tags = append(tags, ref.Name)
}

if int64(page) >= resp.NextPage {
// GitLab reports no next page once the last one has been visited.
if resp.NextPage == 0 {
break
}
page++
page = resp.NextPage
}

/*
The repository does hold tags but the age filter discarded every one of them,
which means the tag we would have returned is still cooling down. That's not a
lookup failure, so the sentinel lets the caller skip rather than fail.
*/
if !tagAge.IsZero() && foundTag && len(tags) == 0 {
return nil, fmt.Errorf("%w for the GitLab tags of %s", age.ErrNoVersionMatchingAge, g.getPID())
}

// GitLab returns the most recently updated tag first, while the version filters
// expect the oldest one first, such as when they pick the last element for the
// "latest" kind.
slices.Reverse(tags)

return tags, nil
}

// tagDate returns the date at which a tag was created, and whether GitLab reported one.
// The creation date is absent from older GitLab versions, so the committer date of the
// commit the tag points at is used as a fallback.
func tagDate(t *gitlab.Tag) (time.Time, bool) {
if t.CreatedAt != nil && !t.CreatedAt.IsZero() {
return *t.CreatedAt, true
}
if t.Commit != nil && t.Commit.CommittedDate != nil && !t.Commit.CommittedDate.IsZero() {
return *t.Commit.CommittedDate, true
}
return time.Time{}, false
}

func (s Spec) Validate() error {
gotError := false
missingParameters := []string{}
Expand All @@ -138,6 +193,11 @@ func (s Spec) Validate() error {
missingParameters = append(missingParameters, "repository")
}

if err := s.Age.Validate(); err != nil {
gotError = true
logrus.Errorln(err)
}

if len(missingParameters) > 0 {
logrus.Errorf("missing parameter(s) [%s]", strings.Join(missingParameters, ","))
}
Expand All @@ -160,6 +220,7 @@ func (g *Gitlab) ReportConfig() interface{} {
URL: g.spec.URL,
},
VersionFilter: g.spec.VersionFilter,
Age: g.spec.Age,
Tag: g.spec.Tag,
}
}
Expand Down
74 changes: 74 additions & 0 deletions pkg/plugins/resources/gitlab/tag/main_test.go
Loading
Loading