From fd9d5ca08034d6427cd300e988dbe242ef09e992 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Mon, 9 Mar 2026 21:05:51 -0600 Subject: [PATCH] feat(releases): add GitLab release fetcher GitLab's API differs from GitHub: different URL pattern (/api/v4/projects/:id/releases), nested asset structure (sources + links), page/per_page pagination with X-Total-Pages header, and PRIVATE-TOKEN auth. --- internal/releases/gitlab/gitlab.go | 122 ++++++++++++++++ internal/releases/gitlab/gitlab_test.go | 182 ++++++++++++++++++++++++ 2 files changed, 304 insertions(+) create mode 100644 internal/releases/gitlab/gitlab.go create mode 100644 internal/releases/gitlab/gitlab_test.go diff --git a/internal/releases/gitlab/gitlab.go b/internal/releases/gitlab/gitlab.go new file mode 100644 index 0000000..c50f2eb --- /dev/null +++ b/internal/releases/gitlab/gitlab.go @@ -0,0 +1,122 @@ +// Package gitlab fetches releases from a GitLab instance. +// +// GitLab's releases API differs from GitHub's in structure: +// +// GET /api/v4/projects/:id/releases +// +// Where :id is the URL-encoded project path (e.g. "group%2Frepo") or a +// numeric project ID. Assets are split into auto-generated source archives +// and manually attached links. Pagination uses page/per_page query params +// and X-Total-Pages response headers (not Link headers). +// +// This package handles pagination, authentication, and deserialization. +// It does not transform or normalize the data. +package gitlab + +import ( + "context" + "encoding/json" + "fmt" + "iter" + "net/http" + "net/url" + "strconv" +) + +// Release is one release from the GitLab releases API. +type Release struct { + TagName string `json:"tag_name"` + Name string `json:"name"` + ReleasedAt string `json:"released_at"` // "2025-10-22T13:00:26Z" + Assets Assets `json:"assets"` +} + +// Assets holds both auto-generated source archives and attached links. +type Assets struct { + Sources []Source `json:"sources"` + Links []Link `json:"links"` +} + +// Source is an auto-generated source archive (tar.gz, zip, etc.). +type Source struct { + Format string `json:"format"` // "zip", "tar.gz", "tar.bz2", "tar" + URL string `json:"url"` +} + +// Link is a file attached to a release (binary, package, etc.). +type Link struct { + ID int `json:"id"` + Name string `json:"name"` + URL string `json:"url"` + DirectAssetPath string `json:"direct_asset_path"` + LinkType string `json:"link_type"` // "other", "runbook", "image", "package" +} + +// Auth holds optional credentials for authenticated API access. +type Auth struct { + Token string // personal access token or deploy token +} + +// Fetch retrieves releases from a GitLab instance, paginating automatically. +// Each yield is one page of releases. +// +// The baseURL should be the GitLab root (e.g. "https://gitlab.com"). +// The project is identified by its path (e.g. "group/repo") — it will be +// URL-encoded automatically. +func Fetch(ctx context.Context, client *http.Client, baseURL, project string, auth *Auth) iter.Seq2[[]Release, error] { + return func(yield func([]Release, error) bool) { + encodedProject := url.PathEscape(project) + page := 1 + + for { + reqURL := fmt.Sprintf("%s/api/v4/projects/%s/releases?per_page=100&page=%d", + baseURL, encodedProject, page) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + if err != nil { + yield(nil, fmt.Errorf("gitlab: %w", err)) + return + } + req.Header.Set("Accept", "application/json") + if auth != nil && auth.Token != "" { + req.Header.Set("PRIVATE-TOKEN", auth.Token) + } + + resp, err := client.Do(req) + if err != nil { + yield(nil, fmt.Errorf("gitlab: fetch %s: %w", reqURL, err)) + return + } + + if resp.StatusCode != http.StatusOK { + resp.Body.Close() + yield(nil, fmt.Errorf("gitlab: fetch %s: %s", reqURL, resp.Status)) + return + } + + var releases []Release + err = json.NewDecoder(resp.Body).Decode(&releases) + resp.Body.Close() + if err != nil { + yield(nil, fmt.Errorf("gitlab: decode %s: %w", reqURL, err)) + return + } + + if !yield(releases, nil) { + return + } + + // Check if there are more pages. + totalPages := 1 + if tp := resp.Header.Get("X-Total-Pages"); tp != "" { + if n, err := strconv.Atoi(tp); err == nil { + totalPages = n + } + } + if page >= totalPages { + return + } + page++ + } + } +} diff --git a/internal/releases/gitlab/gitlab_test.go b/internal/releases/gitlab/gitlab_test.go new file mode 100644 index 0000000..f7be0dc --- /dev/null +++ b/internal/releases/gitlab/gitlab_test.go @@ -0,0 +1,182 @@ +package gitlab_test + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/webinstall/webi-installers/internal/releases/gitlab" +) + +const page1 = `[ + { + "tag_name": "v2.0.0", + "name": "v2.0.0", + "released_at": "2025-06-01T12:00:00Z", + "assets": { + "sources": [ + {"format": "tar.gz", "url": "https://example.com/archive/v2.0.0.tar.gz"}, + {"format": "zip", "url": "https://example.com/archive/v2.0.0.zip"} + ], + "links": [ + { + "id": 1, + "name": "tool-v2.0.0-linux-amd64.tar.gz", + "url": "https://example.com/tool-v2.0.0-linux-amd64.tar.gz", + "direct_asset_path": "/binaries/linux-amd64", + "link_type": "package" + } + ] + } + } +]` + +const page2 = `[ + { + "tag_name": "v1.0.0", + "name": "v1.0.0", + "released_at": "2024-01-15T08:00:00Z", + "assets": { + "sources": [ + {"format": "tar.gz", "url": "https://example.com/archive/v1.0.0.tar.gz"} + ], + "links": [] + } + } +]` + +func TestFetchPagination(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Go's http server decodes %2F back to /, so check RawPath + // for the encoded form or Path for the decoded form. + wantRaw := "/api/v4/projects/group%2Ftool/releases" + wantDecoded := "/api/v4/projects/group/tool/releases" + if r.URL.RawPath != wantRaw && r.URL.Path != wantDecoded { + t.Errorf("unexpected path: raw=%q decoded=%q", r.URL.RawPath, r.URL.Path) + http.NotFound(w, r) + return + } + + page := r.URL.Query().Get("page") + w.Header().Set("X-Total-Pages", "2") + + switch page { + case "", "1": + w.Write([]byte(page1)) + case "2": + w.Write([]byte(page2)) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + ctx := context.Background() + var batches int + var allReleases []gitlab.Release + + for releases, err := range gitlab.Fetch(ctx, srv.Client(), srv.URL, "group/tool", nil) { + if err != nil { + t.Fatalf("batch %d: %v", batches, err) + } + batches++ + allReleases = append(allReleases, releases...) + } + + if batches != 2 { + t.Errorf("got %d batches, want 2", batches) + } + if len(allReleases) != 2 { + t.Fatalf("got %d releases, want 2", len(allReleases)) + } + + // Page 1: v2.0.0 + r1 := allReleases[0] + if r1.TagName != "v2.0.0" { + t.Errorf("release[0].TagName = %q, want %q", r1.TagName, "v2.0.0") + } + if len(r1.Assets.Sources) != 2 { + t.Errorf("release[0] has %d sources, want 2", len(r1.Assets.Sources)) + } + if len(r1.Assets.Links) != 1 { + t.Errorf("release[0] has %d links, want 1", len(r1.Assets.Links)) + } + if r1.Assets.Links[0].LinkType != "package" { + t.Errorf("release[0] link type = %q, want %q", r1.Assets.Links[0].LinkType, "package") + } + + // Page 2: v1.0.0 + r2 := allReleases[1] + if r2.TagName != "v1.0.0" { + t.Errorf("release[1].TagName = %q, want %q", r2.TagName, "v1.0.0") + } + if len(r2.Assets.Links) != 0 { + t.Errorf("release[1] has %d links, want 0", len(r2.Assets.Links)) + } +} + +func TestFetchAuth(t *testing.T) { + var gotAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("PRIVATE-TOKEN") + w.Write([]byte("[]")) + })) + defer srv.Close() + + ctx := context.Background() + auth := &gitlab.Auth{Token: "glpat-test123"} + for _, err := range gitlab.Fetch(ctx, srv.Client(), srv.URL, "group/tool", auth) { + if err != nil { + t.Fatal(err) + } + } + + if gotAuth != "glpat-test123" { + t.Errorf("PRIVATE-TOKEN = %q, want %q", gotAuth, "glpat-test123") + } +} + +func TestFetchSinglePage(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // No X-Total-Pages header — defaults to 1 page. + w.Write([]byte(page1)) + })) + defer srv.Close() + + ctx := context.Background() + var batches int + for _, err := range gitlab.Fetch(ctx, srv.Client(), srv.URL, "group/tool", nil) { + if err != nil { + t.Fatal(err) + } + batches++ + } + + if batches != 1 { + t.Errorf("got %d batches, want 1 (no X-Total-Pages means single page)", batches) + } +} + +func TestFetchEarlyBreak(t *testing.T) { + var requests int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + w.Header().Set("X-Total-Pages", "10") + w.Write([]byte(fmt.Sprintf(`[{"tag_name":"v%d.0.0","name":"","released_at":"2025-01-01T00:00:00Z","assets":{"sources":[],"links":[]}}]`, requests))) + })) + defer srv.Close() + + ctx := context.Background() + for _, err := range gitlab.Fetch(ctx, srv.Client(), srv.URL, "group/tool", nil) { + if err != nil { + t.Fatal(err) + } + break // stop after first page + } + + if requests != 1 { + t.Errorf("server received %d requests, want 1", requests) + } +}