From e1bd6bb82f5cd38c67f66fe612e75c67b804de5f Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Mon, 9 Mar 2026 21:06:58 -0600 Subject: [PATCH] ref(gitea): rewrite as standalone fetcher, not a githubish wrapper Gitea's API is similar to GitHub's but not identical (different URL prefix, limit vs per_page, token auth header). Give it its own types and pagination logic rather than coupling through githubish. --- internal/releases/gitea/gitea.go | 116 +++++++++++++++++++++++--- internal/releases/gitea/gitea_test.go | 107 ++++++++++++++++++++++++ 2 files changed, 213 insertions(+), 10 deletions(-) create mode 100644 internal/releases/gitea/gitea_test.go diff --git a/internal/releases/gitea/gitea.go b/internal/releases/gitea/gitea.go index a2f691b..036de4d 100644 --- a/internal/releases/gitea/gitea.go +++ b/internal/releases/gitea/gitea.go @@ -1,24 +1,120 @@ -// Package gitea fetches releases from a Gitea (or Forgejo) instance. +// Package gitea fetches releases from a Gitea or Forgejo instance. // -// Gitea's release API is GitHub-compatible but lives under /api/v1: +// Gitea's release API lives under: // // GET {baseurl}/api/v1/repos/{owner}/{repo}/releases // -// This package appends the /api/v1 prefix and delegates to [githubish]. +// The response shape is similar to GitHub's but not identical. This package +// handles pagination, authentication, and deserialization independently. package gitea import ( "context" + "encoding/json" + "fmt" "iter" "net/http" + "regexp" "strings" - - "github.com/webinstall/webi-installers/internal/releases/githubish" ) -// Fetch retrieves releases from a Gitea instance. -// The baseURL should be the Gitea root (e.g. "https://git.rootprojects.org"), -// not the API path — /api/v1 is appended automatically. -func Fetch(ctx context.Context, client *http.Client, baseURL, owner, repo string, auth *githubish.Auth) iter.Seq2[[]githubish.Release, error] { - return githubish.Fetch(ctx, client, strings.TrimRight(baseURL, "/")+"/api/v1", owner, repo, auth) +// Release is one release from the Gitea releases API. +type Release struct { + TagName string `json:"tag_name"` + Name string `json:"name"` + Prerelease bool `json:"prerelease"` + Draft bool `json:"draft"` + PublishedAt string `json:"published_at"` // "2023-11-05T06:38:05Z" + Assets []Asset `json:"assets"` + TarballURL string `json:"tarball_url"` + ZipballURL string `json:"zipball_url"` +} + +// Asset is one downloadable file attached to a release. +type Asset struct { + Name string `json:"name"` // "pathman-v0.6.0-darwin-amd64.tar.gz" + BrowserDownloadURL string `json:"browser_download_url"` // full URL + Size int64 `json:"size"` +} + +// Auth holds optional credentials for authenticated API access. +type Auth struct { + Token string // personal access token or API key +} + +// Fetch retrieves releases from a Gitea instance, paginating automatically. +// Each yield is one page of releases. +// +// The baseURL should be the Gitea root (e.g. "https://git.rootprojects.org"). +// The /api/v1 prefix is appended automatically. +func Fetch(ctx context.Context, client *http.Client, baseURL, owner, repo string, auth *Auth) iter.Seq2[[]Release, error] { + return func(yield func([]Release, error) bool) { + base := strings.TrimRight(baseURL, "/") + page := 1 + + for { + url := fmt.Sprintf("%s/api/v1/repos/%s/%s/releases?limit=50&page=%d", + base, owner, repo, page) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + yield(nil, fmt.Errorf("gitea: %w", err)) + return + } + req.Header.Set("Accept", "application/json") + if auth != nil && auth.Token != "" { + req.Header.Set("Authorization", "token "+auth.Token) + } + + resp, err := client.Do(req) + if err != nil { + yield(nil, fmt.Errorf("gitea: fetch %s: %w", url, err)) + return + } + + if resp.StatusCode != http.StatusOK { + resp.Body.Close() + yield(nil, fmt.Errorf("gitea: fetch %s: %s", url, resp.Status)) + return + } + + var releases []Release + err = json.NewDecoder(resp.Body).Decode(&releases) + resp.Body.Close() + if err != nil { + yield(nil, fmt.Errorf("gitea: decode %s: %w", url, err)) + return + } + + if !yield(releases, nil) { + return + } + + // Gitea uses Link headers like GitHub for pagination. + if nextURL := nextPageURL(resp.Header.Get("Link")); nextURL != "" { + url = nextURL + page++ // not strictly needed since we follow the URL, but keeps logic clear + continue + } + + // No next link — also stop if we got fewer results than requested. + if len(releases) < 50 { + return + } + page++ + } + } +} + +var reNextLink = regexp.MustCompile(`<([^>]+)>;\s*rel="next"`) + +func nextPageURL(link string) string { + if link == "" { + return "" + } + m := reNextLink.FindStringSubmatch(link) + if m == nil { + return "" + } + return m[1] } diff --git a/internal/releases/gitea/gitea_test.go b/internal/releases/gitea/gitea_test.go new file mode 100644 index 0000000..de6d894 --- /dev/null +++ b/internal/releases/gitea/gitea_test.go @@ -0,0 +1,107 @@ +package gitea_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/webinstall/webi-installers/internal/releases/gitea" +) + +const testReleases = `[ + { + "tag_name": "v0.6.0", + "name": "v0.6.0", + "prerelease": false, + "draft": false, + "published_at": "2023-11-05T06:38:05Z", + "tarball_url": "https://example.com/archive/v0.6.0.tar.gz", + "zipball_url": "https://example.com/archive/v0.6.0.zip", + "assets": [ + { + "name": "tool-v0.6.0-linux-amd64.tar.gz", + "browser_download_url": "https://example.com/releases/download/v0.6.0/tool-v0.6.0-linux-amd64.tar.gz", + "size": 89215 + } + ] + } +]` + +func TestFetch(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/repos/root/tool/releases" { + t.Errorf("unexpected path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + w.Write([]byte(testReleases)) + })) + defer srv.Close() + + ctx := context.Background() + var all []gitea.Release + + for releases, err := range gitea.Fetch(ctx, srv.Client(), srv.URL, "root", "tool", nil) { + if err != nil { + t.Fatal(err) + } + all = append(all, releases...) + } + + if len(all) != 1 { + t.Fatalf("got %d releases, want 1", len(all)) + } + if all[0].TagName != "v0.6.0" { + t.Errorf("TagName = %q, want %q", all[0].TagName, "v0.6.0") + } + if len(all[0].Assets) != 1 { + t.Errorf("got %d assets, want 1", len(all[0].Assets)) + } + if all[0].TarballURL == "" { + t.Error("TarballURL is empty") + } +} + +func TestFetchAuth(t *testing.T) { + var gotAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + w.Write([]byte("[]")) + })) + defer srv.Close() + + ctx := context.Background() + auth := &gitea.Auth{Token: "abc123"} + for _, err := range gitea.Fetch(ctx, srv.Client(), srv.URL, "root", "tool", auth) { + if err != nil { + t.Fatal(err) + } + } + + if gotAuth != "token abc123" { + t.Errorf("Authorization = %q, want %q", gotAuth, "token abc123") + } +} + +func TestFetchLive(t *testing.T) { + if testing.Short() { + t.Skip("skipping network test in short mode") + } + + ctx := context.Background() + client := &http.Client{} + + var total int + for releases, err := range gitea.Fetch(ctx, client, "https://git.rootprojects.org", "root", "pathman", nil) { + if err != nil { + t.Fatal(err) + } + total += len(releases) + } + + if total < 1 { + t.Errorf("got %d releases, expected at least 1", total) + } + t.Logf("fetched %d releases", total) +}