cli/pkg/updater/github.go
Snider 4e02d5bc97 refactor: bring external packages home and restructure
- Imported packages from separate repos:
  - github.com/Snider/config -> pkg/config
  - github.com/Snider/display -> pkg/display
  - github.com/Snider/help -> pkg/help
  - github.com/Snider/i18n -> pkg/i18n
  - github.com/Snider/updater -> pkg/updater
- Moved core code from root to pkg/core
- Flattened nested package structures
- Updated all import paths to github.com/Snider/Core/pkg/*
- Added Display interface to Core
- Updated go.work for workspace modules

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 15:30:43 +00:00

302 lines
9.3 KiB
Go

package updater
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"runtime"
"strings"
"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 creates a new HTTP client that authenticates with the GitHub API.
// It uses the GITHUB_TOKEN environment variable for authentication.
// If the token is not set, it returns the default HTTP client.
var NewAuthenticatedClient = func(ctx context.Context) *http.Client {
token := os.Getenv("GITHUB_TOKEN")
if token == "" {
return http.DefaultClient
}
ts := oauth2.StaticTokenSource(
&oauth2.Token{AccessToken: token},
)
return oauth2.NewClient(ctx, ts)
}
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 := fmt.Sprintf("%s/users/%s/repos", apiURL, userOrOrg)
for {
if err := ctx.Err(); err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "Borg-Data-Collector")
resp, err := client.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
// Try organization endpoint
url = fmt.Sprintf("%s/orgs/%s/repos", apiURL, userOrOrg)
req, err = http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "Borg-Data-Collector")
resp, err = client.Do(req)
if err != nil {
return nil, err
}
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return nil, fmt.Errorf("failed to fetch repos: %s", resp.Status)
}
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 latest release for a given repository and channel.
// The channel can be "stable", "beta", or "alpha".
func (g *githubClient) GetLatestRelease(ctx context.Context, owner, repo, channel string) (*Release, error) {
client := NewAuthenticatedClient(ctx)
url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases", owner, repo)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "Borg-Data-Collector")
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to fetch releases: %s", resp.Status)
}
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 a release associated with a specific pull request number.
func (g *githubClient) GetReleaseByPullRequest(ctx context.Context, owner, repo string, prNumber int) (*Release, error) {
client := NewAuthenticatedClient(ctx)
url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases", owner, repo)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "Borg-Data-Collector")
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to fetch releases: %s", resp.Status)
}
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 with the format `vX.Y.Z-alpha.pr.123` or `vX.Y.Z-beta.pr.123`
prTagSuffix := fmt.Sprintf(".pr.%d", 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 "", fmt.Errorf("no release provided")
}
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 "", fmt.Errorf("no suitable download asset found for %s/%s", osName, archName)
}