mirror of
https://github.com/webinstall/webi-installers.git
synced 2026-04-07 02:46:50 +00:00
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.
This commit is contained in:
122
internal/releases/gitlab/gitlab.go
Normal file
122
internal/releases/gitlab/gitlab.go
Normal file
@@ -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++
|
||||
}
|
||||
}
|
||||
}
|
||||
182
internal/releases/gitlab/gitlab_test.go
Normal file
182
internal/releases/gitlab/gitlab_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user