diff --git a/terminal/README.md b/terminal/README.md index aca146e..2fe0d90 100644 --- a/terminal/README.md +++ b/terminal/README.md @@ -10,7 +10,7 @@ The `terminal` package provides a collection of utilities to manage terminal int - **CI Detection**: Identify if the program is running in a Continuous Integration (CI) environment. - **Homebrew Utilities**: Check for Homebrew installation and verify binary paths. - **Browser Launching**: Open URLs in the default web browser, with cross-platform support. -- + ## Installation To include this package in your Go project, use: @@ -95,7 +95,7 @@ if terminal.IsCI() { Use `IsCI` to check if the program is running in a CI environment. ```go -if termutil.IsCI() { +if terminal.IsCI() { fmt.Println("Running in a Continuous Integration environment.") } else { fmt.Println("Not running in a CI environment.") @@ -107,7 +107,7 @@ if termutil.IsCI() { Use `HasHomebrew` to check if Homebrew is installed on the system. ```go -if termuinal.HasHomebrew() { +if terminal.HasHomebrew() { fmt.Println("Homebrew is installed!") } else { fmt.Println("Homebrew is not installed.") diff --git a/version/README.md b/version/README.md new file mode 100644 index 0000000..55274ab --- /dev/null +++ b/version/README.md @@ -0,0 +1,100 @@ +# Version + +The `version` package provides utilities to fetch and compare software version information from GitHub releases. It helps check if a newer version is available and generates update notices. + +## Features + +- **Fetch Release Information**: Retrieve the latest release details from a GitHub repository. +- **Version Comparison**: Compare semantic versions to determine if an update is needed. +- **Update Notifications**: Generate user-friendly messages if a newer version is available. + +## Installation + +To include this package in your Go project, use: + +```bash +go get github.com/raystack/salt/version +``` + +## Usage + +### 1. Fetching Release Information + +You can use the `ReleaseInfo` function to fetch the latest release details from a GitHub repository. + +```go +package main + +import ( + "fmt" + "github.com/raystack/salt/version" +) + +func main() { + releaseURL := "https://api.github.com/repos/raystack/optimus/releases/latest" + info, err := version.ReleaseInfo(releaseURL) + if err != nil { + fmt.Println("Error fetching release info:", err) + return + } + fmt.Printf("Latest Version: %s\nDownload URL: %s\n", info.Version, info.TarURL) +} +``` + +### 2. Comparing Versions + +Use `IsCurrentLatest` to check if the current version is up-to-date with the latest release. + +```go +currVersion := "1.2.3" +latestVersion := "1.2.4" +isLatest, err := version.IsCurrentLatest(currVersion, latestVersion) +if err != nil { + fmt.Println("Error comparing versions:", err) +} else if isLatest { + fmt.Println("You are using the latest version!") +} else { + fmt.Println("A newer version is available.") +} +``` + +### 3. Generating Update Notices + +`UpdateNotice` generates a message prompting the user to update if a newer version is available. + +```go +notice := version.UpdateNotice("1.0.0", "raystack/optimus") +if notice != "" { + fmt.Println(notice) +} else { + fmt.Println("You are up-to-date!") +} +``` + +## API Reference + +### Functions + +- `ReleaseInfo(releaseURL string) (*Info, error)`: Fetches the latest release information from the given GitHub API URL. +- `IsCurrentLatest(currVersion, latestVersion string) (bool, error)`: Compares the current version with the latest version using semantic versioning. +- `UpdateNotice(currentVersion, githubRepo string) string`: Returns an update notice if a newer version is available, or an empty string if up-to-date. + +### Structs + +- `type Info`: Contains details about a release. + - `Version`: The version string (e.g., "v1.2.3"). + - `TarURL`: The tarball URL for downloading the release. + +## Environment Variables + +- The `User-Agent` header in HTTP requests is set to `raystack/salt` to comply with GitHub's API requirements. + +## Error Handling + +- Uses `github.com/pkg/errors` to wrap errors for better error context. +- Returns errors when HTTP requests fail, or when JSON parsing or version comparison fails. + +## Dependencies + +- `github.com/hashicorp/go-version`: For semantic version comparison. +- `github.com/pkg/errors`: For enhanced error wrapping. diff --git a/version/release.go b/version/release.go index 9fad3d2..8897b5d 100644 --- a/version/release.go +++ b/version/release.go @@ -3,7 +3,7 @@ package version import ( "encoding/json" "fmt" - "io/ioutil" + "io" "net/http" "time" @@ -12,76 +12,100 @@ import ( ) var ( + // ReleaseInfoTimeout sets the HTTP client timeout for fetching release info. ReleaseInfoTimeout = time.Second * 1 - Release = "https://api.github.com/repos/%s/releases/latest" + + // Release is the GitHub API URL template to fetch the latest release of a repository. + Release = "https://api.github.com/repos/%s/releases/latest" ) +// Info holds information about a software release. type Info struct { - Version string - TarURL string + Version string // Version of the release + TarURL string // Tarball URL of the release } -// ReleaseInfo fetches details related to provided release URL -// releaseURL should point to a specific version -// for example: https://api.github.com/repos/raystack/optimus/releases/latest +// ReleaseInfo fetches details related to the latest release from the provided URL. +// +// Parameters: +// - releaseURL: The URL to fetch the latest release information from. +// Example: "https://api.github.com/repos/raystack/optimus/releases/latest" +// +// Returns: +// - An *Info struct containing the version and tarball URL. +// - An error if the HTTP request or response parsing fails. func ReleaseInfo(releaseURL string) (*Info, error) { httpClient := http.Client{ Timeout: ReleaseInfoTimeout, } req, err := http.NewRequest(http.MethodGet, releaseURL, nil) if err != nil { - return nil, errors.Wrapf(err, "failed to create request") + return nil, errors.Wrap(err, "failed to create request") } req.Header.Set("User-Agent", "raystack/salt") + resp, err := httpClient.Do(req) if err != nil { return nil, errors.Wrapf(err, "failed to reach releaseURL: %s", releaseURL) } + defer func() { + if resp.Body != nil { + resp.Body.Close() + } + }() if resp.StatusCode != http.StatusOK { - return nil, errors.Wrapf(err, "failed to reach releaseURL: %s, returned: %d", releaseURL, resp.StatusCode) - } - if resp.Body != nil { - defer resp.Body.Close() + return nil, errors.Wrapf(err, "failed to reach releaseURL: %s, status code: %d", releaseURL, resp.StatusCode) } - body, err := ioutil.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) if err != nil { - return nil, errors.Wrapf(err, "failed to read response body") + return nil, errors.Wrap(err, "failed to read response body") } - var releaseBody struct { + var releaseData struct { TagName string `json:"tag_name"` Tarball string `json:"tarball_url"` } - if err = json.Unmarshal(body, &releaseBody); err != nil { - return nil, errors.Wrapf(err, "failed to parse: %s", string(body)) + if err = json.Unmarshal(body, &releaseData); err != nil { + return nil, errors.Wrapf(err, "failed to parse JSON response: %s", string(body)) } return &Info{ - Version: releaseBody.TagName, - TarURL: releaseBody.Tarball, + Version: releaseData.TagName, + TarURL: releaseData.Tarball, }, nil } -// IsCurrentLatest returns true if the current version string is greater than -// or equal to latestVersion as per semantic versioning +// IsCurrentLatest compares the current version with the latest version. +// +// Parameters: +// - currVersion: The current version string. +// - latestVersion: The latest version string. +// +// Returns: +// - true if the current version is greater than or equal to the latest version. +// - An error if version parsing fails. func IsCurrentLatest(currVersion, latestVersion string) (bool, error) { currentV, err := version.NewVersion(currVersion) if err != nil { - return false, errors.Wrapf(err, "failed to parse current version") + return false, errors.Wrap(err, "failed to parse current version") } latestV, err := version.NewVersion(latestVersion) if err != nil { - return false, errors.Wrapf(err, "failed to parse latest version") - } - if currentV.GreaterThanOrEqual(latestV) { - return true, nil + return false, errors.Wrap(err, "failed to parse latest version") } - return false, nil + return currentV.GreaterThanOrEqual(latestV), nil } -// UpdateNotice returns a notice message if there is a newer version available -// Note: all errors are ignored +// UpdateNotice generates a notice message if a newer version is available. +// +// Parameters: +// - currentVersion: The current version string. +// - githubRepo: The GitHub repository in the format "owner/repo". +// +// Returns: +// - A string message prompting the user to update if a newer version is available. +// - An empty string if there are no updates or if any errors occur. func UpdateNotice(currentVersion, githubRepo string) string { info, err := ReleaseInfo(fmt.Sprintf(Release, githubRepo)) if err != nil { @@ -89,11 +113,8 @@ func UpdateNotice(currentVersion, githubRepo string) string { } latestVersion := info.Version isCurrentLatest, err := IsCurrentLatest(currentVersion, latestVersion) - if err != nil { - return "" - } - if isCurrentLatest { + if err != nil || isCurrentLatest { return "" } - return fmt.Sprintf("A new release (%s) is available, consider updating the client.", info.Version) + return fmt.Sprintf("A new release (%s) is available, consider updating the client.", latestVersion) } diff --git a/version/release_test.go b/version/release_test.go index 7003468..cc50c09 100644 --- a/version/release_test.go +++ b/version/release_test.go @@ -1,104 +1,109 @@ -package version_test +package version import ( - "encoding/json" "net/http" "net/http/httptest" "testing" - "github.com/gorilla/mux" - "github.com/raystack/salt/version" "github.com/stretchr/testify/assert" ) -func TestGithubInfo(t *testing.T) { - muxRouter := mux.NewRouter() - server := httptest.NewServer(muxRouter) +func TestReleaseInfo_Success(t *testing.T) { + // Mock a successful GitHub API response + mockResponse := `{ + "tag_name": "v1.2.3", + "tarball_url": "https://example.com/tarball/v1.2.3" + }` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(mockResponse)) + })) + defer server.Close() - t.Run("should check for latest version availability by extracting correct version tag for valid json response on release URL", func(t *testing.T) { - muxRouter.HandleFunc("/latest", func(rw http.ResponseWriter, r *http.Request) { - rw.Header().Set("Content-Type", "application/json") - response, _ := json.Marshal(struct { - TagName string `json:"tag_name"` - }{ - TagName: "v0.0.2", - }) - rw.Write(response) - }) - info, err := version.ReleaseInfo("http://" + server.Listener.Addr().String() + "/latest") - assert.Nil(t, err) - assert.Equal(t, "v0.0.2", info.Version) - info, err = version.ReleaseInfo("http://" + server.Listener.Addr().String() + "/latest") - assert.Nil(t, err) - assert.NotEqual(t, "v0.0.1", info.Version) - }) + info, err := ReleaseInfo(server.URL) + assert.NoError(t, err) + assert.Equal(t, "v1.2.3", info.Version) + assert.Equal(t, "https://example.com/tarball/v1.2.3", info.TarURL) } + +func TestReleaseInfo_Failure(t *testing.T) { + // Mock a failed GitHub API response with a non-OK status code + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + info, err := ReleaseInfo(server.URL) + assert.Error(t, err) + assert.Nil(t, info) +} + +func TestReleaseInfo_InvalidJSON(t *testing.T) { + // Mock a response with invalid JSON + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`invalid json`)) + })) + defer server.Close() + + info, err := ReleaseInfo(server.URL) + assert.Error(t, err) + assert.Nil(t, info) +} + func TestIsCurrentLatest(t *testing.T) { - muxRouter := mux.NewRouter() - server := httptest.NewServer(muxRouter) + // Test cases for version comparison + tests := []struct { + currVersion string + latestVersion string + expected bool + shouldError bool + }{ + {"1.2.3", "1.2.2", true, false}, + {"1.2.3", "1.2.3", true, false}, + {"1.2.2", "1.2.3", false, false}, + {"invalid", "1.2.3", false, true}, + {"1.2.3", "invalid", false, true}, + } - t.Run("should return true for current version as the latest version ", func(t *testing.T) { - muxRouter.HandleFunc("/latest", func(rw http.ResponseWriter, r *http.Request) { - rw.Header().Set("Content-Type", "application/json") - response, _ := json.Marshal(struct { - TagName string `json:"tag_name"` - }{ - TagName: "v0.0.2", - }) - rw.Write(response) - }) - info, err := version.ReleaseInfo("http://" + server.Listener.Addr().String() + "/latest") - assert.Nil(t, err) - assert.Equal(t, "v0.0.2", info.Version) - res, err := version.IsCurrentLatest("v0.0.2", info.Version) - assert.Nil(t, err) - assert.True(t, res) - }) - t.Run("should return false for current version not same as the latest version", func(t *testing.T) { - muxRouter.HandleFunc("/latest", func(rw http.ResponseWriter, r *http.Request) { - rw.Header().Set("Content-Type", "application/json") - response, _ := json.Marshal(struct { - TagName string `json:"tag_name"` - }{ - TagName: "v0.0.2", - }) - rw.Write(response) - }) - info, err := version.ReleaseInfo("http://" + server.Listener.Addr().String() + "/latest") - assert.Nil(t, err) - assert.Equal(t, "v0.0.2", info.Version) - res, err := version.IsCurrentLatest("v0.0.1", info.Version) - assert.Nil(t, err) - assert.False(t, res) - res, err = version.IsCurrentLatest("", info.Version) - assert.NotNil(t, err) - assert.False(t, res) - res, err = version.IsCurrentLatest("v0.0.3", "") - assert.NotNil(t, err) - assert.False(t, res) - }) + for _, test := range tests { + result, err := IsCurrentLatest(test.currVersion, test.latestVersion) + if test.shouldError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, test.expected, result) + } + } } func TestUpdateNotice(t *testing.T) { - muxRouter := mux.NewRouter() - server := httptest.NewServer(muxRouter) - t.Run("basic check for notify latest version", func(t *testing.T) { - muxRouter.HandleFunc("/latest", func(rw http.ResponseWriter, r *http.Request) { - rw.Header().Set("Content-Type", "application/json") - response, _ := json.Marshal(struct { - TagName string `json:"tag_name"` - }{ - TagName: "v0.0.1", - }) - rw.Write(response) - }) - info, err := version.ReleaseInfo("http://" + server.Listener.Addr().String() + "/latest") - assert.Nil(t, err) - assert.Equal(t, "v0.0.1", info.Version) - res, err := version.IsCurrentLatest("v0.0.1", info.Version) - assert.Nil(t, err) - assert.True(t, res) - s := version.UpdateNotice("v0.0.1", "raystack/optimus") - assert.NotEqual(t, "v0.0.1", s) - }) + // Mock a successful GitHub API response with a newer version + mockResponse := `{ + "tag_name": "v2.0.0", + "tarball_url": "https://example.com/tarball/v2.0.0" + }` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(mockResponse)) + })) + defer server.Close() + + notice := UpdateNotice("1.0.0", server.URL) + assert.Equal(t, "A new release (v2.0.0) is available, consider updating the client.", notice) + + // Test with the current version being the latest + notice = UpdateNotice("2.0.0", server.URL) + assert.Equal(t, "", notice) +} + +func TestUpdateNotice_ErrorHandling(t *testing.T) { + // Mock a server that returns an error + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + notice := UpdateNotice("1.0.0", server.URL) + assert.Equal(t, "", notice) }