// Package githubish fetches releases from GitHub-compatible APIs. // // GitHub, Gitea, Forgejo, and other forges expose the same releases // endpoint shape: // // GET /repos/{owner}/{repo}/releases // // This package handles pagination (Link headers), authentication, and // deserialization. It does not transform or normalize the data. package githubish import ( "context" "encoding/json" "fmt" "iter" "net/http" "regexp" ) // Release is one release from a GitHub-compatible API. // Fields mirror the upstream JSON — only the fields Webi cares about are // included; the rest are silently dropped by the decoder. 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"` // "2025-10-22T13:00:26Z" Assets []Asset `json:"assets"` TarballURL string `json:"tarball_url"` // auto-generated source tarball ZipballURL string `json:"zipball_url"` // auto-generated source zipball } // Asset is one downloadable file attached to a release. type Asset struct { Name string `json:"name"` // "ripgrep-15.1.0-x86_64-apple-darwin.tar.gz" BrowserDownloadURL string `json:"browser_download_url"` // full URL Size int64 `json:"size"` ContentType string `json:"content_type"` } // Auth holds optional credentials for authenticated API access. // Without auth, GitHub's public rate limit is 60 requests/hour. type Auth struct { Token string // personal access token or fine-grained token } // Fetch retrieves releases from a GitHub-compatible API, paginating // automatically. Each yield is one page of releases. // // The baseURL should be the API root (e.g. "https://api.github.com"). // For Gitea: "https://gitea.example.com/api/v1". 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) { url := fmt.Sprintf("%s/repos/%s/%s/releases?per_page=100", baseURL, owner, repo) for url != "" { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { yield(nil, fmt.Errorf("githubish: %w", err)) return } req.Header.Set("Accept", "application/json") if auth != nil && auth.Token != "" { req.Header.Set("Authorization", "Bearer "+auth.Token) } resp, err := client.Do(req) if err != nil { yield(nil, fmt.Errorf("githubish: fetch %s: %w", url, err)) return } if resp.StatusCode != http.StatusOK { resp.Body.Close() yield(nil, fmt.Errorf("githubish: 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("githubish: decode %s: %w", url, err)) return } if !yield(releases, nil) { return } url = nextPageURL(resp.Header.Get("Link")) } } } // reNextLink matches `; rel="next"` in a Link header. var reNextLink = regexp.MustCompile(`<([^>]+)>;\s*rel="next"`) // nextPageURL extracts the "next" URL from a GitHub Link header. // Returns "" if there is no next page. func nextPageURL(link string) string { if link == "" { return "" } m := reNextLink.FindStringSubmatch(link) if m == nil { return "" } return m[1] }