Replace all remaining fmt.Errorf and errors.New calls in production code with coreerr.E() for consistent error handling with context information. This improves error messages by including the function context where errors occur. Modified files: - github.go: 6 fmt.Errorf calls - generic_http.go: 5 fmt.Errorf calls - updater.go: 6 fmt.Errorf calls - service.go: 4 fmt.Errorf calls - github_test.go: Updated test expectation for new error format All tests pass with the new error format. Co-Authored-By: Virgil <virgil@lethean.io>
303 lines
9.6 KiB
Go
303 lines
9.6 KiB
Go
package updater
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"runtime"
|
|
"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 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, coreerr.E("github.getPublicReposWithAPIURL", fmt.Sprintf("failed to fetch repos: %s", 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 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 func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, coreerr.E("github.GetLatestRelease", fmt.Sprintf("failed to fetch releases: %s", 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 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 func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, coreerr.E("github.GetReleaseByPullRequest", fmt.Sprintf("failed to fetch releases: %s", 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 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 "", 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", fmt.Sprintf("no suitable download asset found for %s/%s", osName, archName), nil)
|
|
}
|