go-update/github.go
Claude 644986b8bb
chore(ax): Pass 1 AX compliance sweep — banned imports, test naming, comment style
- Remove fmt from updater.go, service.go, http_client.go, cmd.go, github.go, generic_http.go; replace with string concat, coreerr.E, cli.Print
- Remove strings from updater.go (inline byte comparisons) and service.go (inline helpers)
- Replace fmt.Sprintf in error paths with string concatenation throughout
- Add cli.Print for all stdout output in updater.go (CheckForUpdates, CheckOnly, etc.)
- Fix service_examples_test.go: restore original CheckForUpdates instead of setting nil
- Test naming: all test files now follow TestFile_Function_{Good,Bad,Ugly} with all three variants mandatory
- Comments: replace prose descriptions with usage-example style on all exported functions
- Remaining banned: strings/encoding/json in github.go and generic_http.go (no Core replacement in direct deps); os/os.exec in platform files (syscall-level, unavoidable without go-process)

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-31 08:42:13 +01:00

305 lines
9.3 KiB
Go

package updater
import (
"context"
"encoding/json"
"net/http"
"os"
"runtime"
"strconv"
"strings"
coreerr "forge.lthn.ai/core/go-log"
"golang.org/x/oauth2"
)
// Repo represents a repository from the GitHub API.
type Repo struct {
CloneURL string `json:"clone_url"` // The URL to clone the repository.
}
// ReleaseAsset represents a single asset from a GitHub release.
type ReleaseAsset struct {
Name string `json:"name"` // The name of the asset.
DownloadURL string `json:"browser_download_url"` // The URL to download the asset.
}
// Release represents a GitHub release.
type Release struct {
TagName string `json:"tag_name"` // The name of the tag for the release.
PreRelease bool `json:"prerelease"` // Indicates if the release is a pre-release.
Assets []ReleaseAsset `json:"assets"` // A list of assets associated with the release.
}
// GithubClient defines the interface for interacting with the GitHub API.
// This allows for mocking the client in tests.
type GithubClient interface {
// GetPublicRepos fetches the public repositories for a user or organization.
GetPublicRepos(ctx context.Context, userOrOrg string) ([]string, error)
// GetLatestRelease fetches the latest release for a given repository and channel.
GetLatestRelease(ctx context.Context, owner, repo, channel string) (*Release, error)
// GetReleaseByPullRequest fetches a release associated with a specific pull request number.
GetReleaseByPullRequest(ctx context.Context, owner, repo string, prNumber int) (*Release, error)
}
type githubClient struct{}
// NewAuthenticatedClient returns an HTTP client using GITHUB_TOKEN if set, or the default client.
//
// client := updater.NewAuthenticatedClient(ctx)
var NewAuthenticatedClient = func(ctx context.Context) *http.Client {
token := os.Getenv("GITHUB_TOKEN")
if token == "" {
return NewHTTPClient()
}
ts := oauth2.StaticTokenSource(
&oauth2.Token{AccessToken: token},
)
client := oauth2.NewClient(ctx, ts)
client.Timeout = defaultHTTPTimeout
return client
}
func (g *githubClient) GetPublicRepos(ctx context.Context, userOrOrg string) ([]string, error) {
return g.getPublicReposWithAPIURL(ctx, "https://api.github.com", userOrOrg)
}
func (g *githubClient) getPublicReposWithAPIURL(ctx context.Context, apiURL, userOrOrg string) ([]string, error) {
client := NewAuthenticatedClient(ctx)
var allCloneURLs []string
url := apiURL + "/users/" + userOrOrg + "/repos"
for {
if err := ctx.Err(); err != nil {
return nil, err
}
req, err := newAgentRequest(ctx, "GET", url)
if err != nil {
return nil, err
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
_ = resp.Body.Close()
// Try organisation endpoint
url = apiURL + "/orgs/" + userOrOrg + "/repos"
req, err = newAgentRequest(ctx, "GET", url)
if err != nil {
return nil, err
}
resp, err = client.Do(req)
if err != nil {
return nil, err
}
}
if resp.StatusCode != http.StatusOK {
_ = resp.Body.Close()
return nil, coreerr.E("github.getPublicReposWithAPIURL", "failed to fetch repos: "+resp.Status, nil)
}
var repos []Repo
if err := json.NewDecoder(resp.Body).Decode(&repos); err != nil {
_ = resp.Body.Close()
return nil, err
}
_ = resp.Body.Close()
for _, repo := range repos {
allCloneURLs = append(allCloneURLs, repo.CloneURL)
}
linkHeader := resp.Header.Get("Link")
if linkHeader == "" {
break
}
nextURL := g.findNextURL(linkHeader)
if nextURL == "" {
break
}
url = nextURL
}
return allCloneURLs, nil
}
func (g *githubClient) findNextURL(linkHeader string) string {
links := strings.Split(linkHeader, ",")
for _, link := range links {
parts := strings.Split(link, ";")
if len(parts) == 2 && strings.TrimSpace(parts[1]) == `rel="next"` {
return strings.Trim(strings.TrimSpace(parts[0]), "<>")
}
}
return ""
}
// GetLatestRelease fetches the first release matching the given channel ("stable", "beta", or "alpha").
//
// release, err := client.GetLatestRelease(ctx, "myorg", "myrepo", "stable")
func (g *githubClient) GetLatestRelease(ctx context.Context, owner, repo, channel string) (*Release, error) {
client := NewAuthenticatedClient(ctx)
releaseURL := "https://api.github.com/repos/" + owner + "/" + repo + "/releases"
req, err := newAgentRequest(ctx, "GET", releaseURL)
if err != nil {
return nil, err
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return nil, coreerr.E("github.GetLatestRelease", "failed to fetch releases: "+resp.Status, nil)
}
var releases []Release
if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil {
return nil, err
}
return filterReleases(releases, channel), nil
}
// filterReleases filters releases based on the specified channel.
func filterReleases(releases []Release, channel string) *Release {
for _, release := range releases {
releaseChannel := determineChannel(release.TagName, release.PreRelease)
if releaseChannel == channel {
return &release
}
}
return nil
}
// determineChannel determines the stability channel of a release based on its tag and PreRelease flag.
func determineChannel(tagName string, isPreRelease bool) string {
tagLower := strings.ToLower(tagName)
if strings.Contains(tagLower, "alpha") {
return "alpha"
}
if strings.Contains(tagLower, "beta") {
return "beta"
}
if isPreRelease { // A pre-release without alpha/beta is treated as beta
return "beta"
}
return "stable"
}
// GetReleaseByPullRequest fetches the release whose tag contains .pr.<prNumber>.
//
// release, err := client.GetReleaseByPullRequest(ctx, "myorg", "myrepo", 42)
func (g *githubClient) GetReleaseByPullRequest(ctx context.Context, owner, repo string, prNumber int) (*Release, error) {
client := NewAuthenticatedClient(ctx)
releaseURL := "https://api.github.com/repos/" + owner + "/" + repo + "/releases"
req, err := newAgentRequest(ctx, "GET", releaseURL)
if err != nil {
return nil, err
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return nil, coreerr.E("github.GetReleaseByPullRequest", "failed to fetch releases: "+resp.Status, nil)
}
var releases []Release
if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil {
return nil, err
}
// The PR number is included in the tag name: vX.Y.Z-alpha.pr.123 or vX.Y.Z-beta.pr.123
prTagSuffix := ".pr." + strconv.Itoa(prNumber)
for _, release := range releases {
if strings.Contains(release.TagName, prTagSuffix) {
return &release, nil
}
}
return nil, nil // No release found for the given PR number
}
// GetDownloadURL finds the appropriate download URL for the current operating system and architecture.
//
// It supports two modes of operation:
// 1. Using a 'releaseURLFormat' template: If 'releaseURLFormat' is provided,
// it will be used to construct the download URL. The template can contain
// placeholders for the release tag '{tag}', operating system '{os}', and
// architecture '{arch}'.
// 2. Automatic detection: If 'releaseURLFormat' is empty, the function will
// inspect the assets of the release to find a suitable download URL. It
// searches for an asset name that contains both the current OS and architecture
// (e.g., "my-app-linux-amd64"). If no match is found, it falls back to
// matching only the OS.
//
// Example with releaseURLFormat:
//
// release := &updater.Release{TagName: "v1.2.3"}
// url, err := updater.GetDownloadURL(release, "https://example.com/downloads/{tag}/{os}/{arch}")
// if err != nil {
// // handle error
// }
// fmt.Println(url) // "https://example.com/downloads/v1.2.3/linux/amd64" (on a Linux AMD64 system)
//
// Example with automatic detection:
//
// release := &updater.Release{
// Assets: []updater.ReleaseAsset{
// {Name: "my-app-linux-amd64", DownloadURL: "https://example.com/download/linux-amd64"},
// {Name: "my-app-windows-amd64", DownloadURL: "https://example.com/download/windows-amd64"},
// },
// }
// url, err := updater.GetDownloadURL(release, "")
// if err != nil {
// // handle error
// }
// fmt.Println(url) // "https://example.com/download/linux-amd64" (on a Linux AMD64 system)
func GetDownloadURL(release *Release, releaseURLFormat string) (string, error) {
if release == nil {
return "", coreerr.E("GetDownloadURL", "no release provided", nil)
}
if releaseURLFormat != "" {
// Replace {tag}, {os}, and {arch} placeholders
r := strings.NewReplacer(
"{tag}", release.TagName,
"{os}", runtime.GOOS,
"{arch}", runtime.GOARCH,
)
return r.Replace(releaseURLFormat), nil
}
osName := runtime.GOOS
archName := runtime.GOARCH
for _, asset := range release.Assets {
assetNameLower := strings.ToLower(asset.Name)
// Match asset that contains both OS and architecture
if strings.Contains(assetNameLower, osName) && strings.Contains(assetNameLower, archName) {
return asset.DownloadURL, nil
}
}
// Fallback for OS only if no asset matched both OS and arch
for _, asset := range release.Assets {
assetNameLower := strings.ToLower(asset.Name)
if strings.Contains(assetNameLower, osName) {
return asset.DownloadURL, nil
}
}
return "", coreerr.E("GetDownloadURL", "no suitable download asset found for "+osName+"/"+archName, nil)
}