mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 01:09:46 -10:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b357a3613c | ||
|
|
b203b8e4f0 | ||
|
|
6a86e103e8 |
No files matched your search
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/tui"
|
||||
"github.com/Gitlawb/zero/internal/updatecheck"
|
||||
"github.com/Gitlawb/zero/internal/verify"
|
||||
"github.com/Gitlawb/zero/internal/worktrees"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
@@ -34,6 +35,7 @@ type appDeps struct {
|
||||
loadHooks func(hooks.LoadOptions) (hooks.LoadResult, error)
|
||||
newMCPStore func() (*mcp.PermissionStore, error)
|
||||
registerMCPTools func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error)
|
||||
checkUpdate func(context.Context, updatecheck.Options) (updatecheck.Result, error)
|
||||
prepareWorktree func(context.Context, worktrees.Options) (worktrees.Result, error)
|
||||
detectVerifyPlan func(string) (verify.Plan, error)
|
||||
runVerify func(context.Context, verify.Plan, verify.RunOptions) verify.Report
|
||||
@@ -90,6 +92,7 @@ func defaultAppDeps() appDeps {
|
||||
registerMCPTools: func(ctx context.Context, registry *tools.Registry, cfg config.MCPConfig, options mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||
return mcp.RegisterTools(ctx, registry, cfg, options)
|
||||
},
|
||||
checkUpdate: updatecheck.Check,
|
||||
prepareWorktree: worktrees.Prepare,
|
||||
detectVerifyPlan: verify.DetectPlan,
|
||||
runVerify: verify.Run,
|
||||
@@ -136,6 +139,8 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
|
||||
return runProviders(args[1:], stdout, stderr, deps)
|
||||
case "doctor":
|
||||
return runDoctor(args[1:], stdout, stderr, deps)
|
||||
case "update":
|
||||
return runUpdate(args[1:], stdout, stderr, deps)
|
||||
case "search", "find":
|
||||
return runSearch(args[1:], stdout, stderr, deps)
|
||||
case "sessions", "session":
|
||||
@@ -195,6 +200,9 @@ func fillAppDeps(deps appDeps) appDeps {
|
||||
if deps.registerMCPTools == nil {
|
||||
deps.registerMCPTools = defaults.registerMCPTools
|
||||
}
|
||||
if deps.checkUpdate == nil {
|
||||
deps.checkUpdate = defaults.checkUpdate
|
||||
}
|
||||
if deps.prepareWorktree == nil {
|
||||
deps.prepareWorktree = defaults.prepareWorktree
|
||||
}
|
||||
@@ -297,6 +305,7 @@ Commands:
|
||||
models List Zero model registry entries
|
||||
providers Inspect resolved provider profiles
|
||||
doctor Run backend health checks for config and provider setup
|
||||
update Check for Zero CLI updates
|
||||
search Search persisted local Zero session events
|
||||
find Alias for search
|
||||
sessions Inspect local Zero session lineage
|
||||
|
||||
@@ -208,6 +208,7 @@ func TestRunCommandsDoNotLaunchTUI(t *testing.T) {
|
||||
{"models"},
|
||||
{"providers"},
|
||||
{"doctor"},
|
||||
{"update"},
|
||||
{"search"},
|
||||
{"find"},
|
||||
{"sessions"},
|
||||
@@ -262,6 +263,7 @@ func assertHelpOutput(t *testing.T, args []string) {
|
||||
"models",
|
||||
"providers",
|
||||
"doctor",
|
||||
"update",
|
||||
"search",
|
||||
"plugins",
|
||||
"hooks",
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/updatecheck"
|
||||
)
|
||||
|
||||
type updateOptions struct {
|
||||
check bool
|
||||
json bool
|
||||
}
|
||||
|
||||
func runUpdate(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
options, help, err := parseUpdateArgs(args)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
if help {
|
||||
if err := writeUpdateHelp(stdout); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
if !options.check {
|
||||
return writeAppError(stderr, "Only `zero update --check` is available right now.", exitUsage)
|
||||
}
|
||||
|
||||
result, err := deps.checkUpdate(context.Background(), updatecheck.Options{
|
||||
CurrentVersion: version,
|
||||
})
|
||||
if err != nil {
|
||||
return writeAppError(stderr, "Could not check for updates: "+err.Error(), 1)
|
||||
}
|
||||
|
||||
if options.json {
|
||||
if err := writePrettyJSON(stdout, result); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
if _, err := fmt.Fprintln(stdout, updatecheck.Format(result)); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
func parseUpdateArgs(args []string) (updateOptions, bool, error) {
|
||||
options := updateOptions{}
|
||||
for _, arg := range args {
|
||||
switch {
|
||||
case arg == "-h" || arg == "--help" || arg == "help":
|
||||
return options, true, nil
|
||||
case arg == "--check":
|
||||
options.check = true
|
||||
case arg == "--json":
|
||||
options.json = true
|
||||
case strings.HasPrefix(arg, "-"):
|
||||
return options, false, execUsageError{fmt.Sprintf("unknown update flag %q", arg)}
|
||||
default:
|
||||
return options, false, execUsageError{fmt.Sprintf("unexpected update argument %q", arg)}
|
||||
}
|
||||
}
|
||||
return options, false, nil
|
||||
}
|
||||
|
||||
func writeUpdateHelp(w io.Writer) error {
|
||||
_, err := fmt.Fprint(w, `Usage:
|
||||
zero update --check [--json]
|
||||
|
||||
Checks the latest GitHub release without installing.
|
||||
|
||||
Flags:
|
||||
--check Check the latest release
|
||||
--json Print the update check result as JSON
|
||||
-h, --help Show this help
|
||||
`)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/updatecheck"
|
||||
)
|
||||
|
||||
func TestRunUpdateRequiresCheck(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
checkCalled := false
|
||||
|
||||
exitCode := runWithDeps([]string{"update"}, &stdout, &stderr, appDeps{
|
||||
checkUpdate: func(context.Context, updatecheck.Options) (updatecheck.Result, error) {
|
||||
checkCalled = true
|
||||
return updatecheck.Result{}, nil
|
||||
},
|
||||
})
|
||||
|
||||
if exitCode != exitUsage {
|
||||
t.Fatalf("expected exit code %d, got %d", exitUsage, exitCode)
|
||||
}
|
||||
if checkCalled {
|
||||
t.Fatal("update check should not run without --check")
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("expected empty stdout, got %q", stdout.String())
|
||||
}
|
||||
if got := stderr.String(); !strings.Contains(got, "Only `zero update --check` is available") {
|
||||
t.Fatalf("expected check-only error, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunUpdateRejectsInvalidArguments(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "unknown flag",
|
||||
args: []string{"update", "--unknown"},
|
||||
want: `unknown update flag "--unknown"`,
|
||||
},
|
||||
{
|
||||
name: "unexpected positional argument",
|
||||
args: []string{"update", "foo"},
|
||||
want: `unexpected update argument "foo"`,
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
exitCode := runWithDeps(test.args, &stdout, &stderr, appDeps{})
|
||||
|
||||
if exitCode != exitUsage {
|
||||
t.Fatalf("expected exit code %d, got %d", exitUsage, exitCode)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("expected empty stdout, got %q", stdout.String())
|
||||
}
|
||||
if got := stderr.String(); !strings.Contains(got, test.want) {
|
||||
t.Fatalf("expected usage error %q, got %q", test.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunUpdateCheckFormatsHumanOutput(t *testing.T) {
|
||||
withVersion(t, "0.1.0")
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
var checkOptions updatecheck.Options
|
||||
|
||||
exitCode := runWithDeps([]string{"update", "--check"}, &stdout, &stderr, appDeps{
|
||||
checkUpdate: func(_ context.Context, options updatecheck.Options) (updatecheck.Result, error) {
|
||||
checkOptions = options
|
||||
return updatecheck.Result{
|
||||
CurrentVersion: "0.1.0",
|
||||
LatestVersion: "0.2.0",
|
||||
ReleaseURL: "https://github.com/Gitlawb/zero/releases/tag/v0.2.0",
|
||||
TagName: "v0.2.0",
|
||||
UpdateAvailable: true,
|
||||
}, nil
|
||||
},
|
||||
})
|
||||
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
||||
}
|
||||
if checkOptions.CurrentVersion != "0.1.0" {
|
||||
t.Fatalf("CurrentVersion = %q, want injected CLI version", checkOptions.CurrentVersion)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, "Update available: 0.1.0 -> 0.2.0") {
|
||||
t.Fatalf("expected update output, got %q", got)
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunUpdateCheckPrintsJSON(t *testing.T) {
|
||||
withVersion(t, "0.1.0")
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
exitCode := runWithDeps([]string{"update", "--check", "--json"}, &stdout, &stderr, appDeps{
|
||||
checkUpdate: func(context.Context, updatecheck.Options) (updatecheck.Result, error) {
|
||||
return updatecheck.Result{
|
||||
CurrentVersion: "0.1.0",
|
||||
LatestVersion: "0.1.0",
|
||||
ReleaseURL: "https://github.com/Gitlawb/zero/releases/tag/v0.1.0",
|
||||
TagName: "v0.1.0",
|
||||
UpdateAvailable: false,
|
||||
}, nil
|
||||
},
|
||||
})
|
||||
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
||||
}
|
||||
var payload updatecheck.Result
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("failed to parse JSON output %q: %v", stdout.String(), err)
|
||||
}
|
||||
if payload.UpdateAvailable {
|
||||
t.Fatalf("expected updateAvailable=false, got %#v", payload)
|
||||
}
|
||||
if payload.CurrentVersion != "0.1.0" || payload.LatestVersion != "0.1.0" {
|
||||
t.Fatalf("unexpected JSON payload: %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunUpdateCheckReportsErrors(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
exitCode := runWithDeps([]string{"update", "--check"}, &stdout, &stderr, appDeps{
|
||||
checkUpdate: func(context.Context, updatecheck.Options) (updatecheck.Result, error) {
|
||||
return updatecheck.Result{}, errors.New("network down")
|
||||
},
|
||||
})
|
||||
|
||||
if exitCode != 1 {
|
||||
t.Fatalf("expected exit code 1, got %d", exitCode)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("expected empty stdout, got %q", stdout.String())
|
||||
}
|
||||
if got := stderr.String(); !strings.Contains(got, "Could not check for updates: network down") {
|
||||
t.Fatalf("expected update error, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunUpdateHelp(t *testing.T) {
|
||||
for _, args := range [][]string{
|
||||
{"update", "--help"},
|
||||
{"update", "-h"},
|
||||
{"update", "help"},
|
||||
} {
|
||||
t.Run(strings.Join(args, " "), func(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
exitCode := runWithDeps(args, &stdout, &stderr, appDeps{})
|
||||
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "zero update --check") {
|
||||
t.Fatalf("expected update help, got %q", stdout.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func withVersion(t *testing.T, value string) {
|
||||
t.Helper()
|
||||
previous := version
|
||||
version = value
|
||||
t.Cleanup(func() {
|
||||
version = previous
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
package updatecheck
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultRepository = "Gitlawb/zero"
|
||||
DefaultUpdateCheckTimeout = 5 * time.Second
|
||||
)
|
||||
|
||||
type HTTPClient interface {
|
||||
Do(*http.Request) (*http.Response, error)
|
||||
}
|
||||
|
||||
type Options struct {
|
||||
CurrentVersion string
|
||||
Endpoint string
|
||||
Repository string
|
||||
Timeout time.Duration
|
||||
Client HTTPClient
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
CurrentVersion string `json:"currentVersion"`
|
||||
LatestVersion string `json:"latestVersion"`
|
||||
ReleaseURL string `json:"releaseUrl"`
|
||||
TagName string `json:"tagName"`
|
||||
UpdateAvailable bool `json:"updateAvailable"`
|
||||
}
|
||||
|
||||
type endpointResolution struct {
|
||||
URL string
|
||||
Repository string
|
||||
}
|
||||
|
||||
type githubReleaseResponse struct {
|
||||
TagName string `json:"tag_name"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
}
|
||||
|
||||
var versionTagPattern = regexp.MustCompile(`^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$`)
|
||||
var repositorySlugPattern = regexp.MustCompile(`^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$`)
|
||||
|
||||
func ReleaseEndpoint(repository string) string {
|
||||
return "https://api.github.com/repos/" + repository + "/releases/latest"
|
||||
}
|
||||
|
||||
func ResolveReleaseEndpoint(endpointOrRepository string, repository string) (string, error) {
|
||||
resolved, err := resolveReleaseEndpoint(endpointOrRepository, repository)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return resolved.URL, nil
|
||||
}
|
||||
|
||||
func NormalizeVersionTag(version string) (string, error) {
|
||||
trimmed := strings.TrimSpace(version)
|
||||
matches := versionTagPattern.FindStringSubmatch(trimmed)
|
||||
if matches == nil {
|
||||
return "", fmt.Errorf("invalid semantic version: %s", version)
|
||||
}
|
||||
|
||||
major, err := parseSemverComponent(matches[1], version)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
minor, err := parseSemverComponent(matches[2], version)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
patch, err := parseSemverComponent(matches[3], version)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%d.%d.%d", major, minor, patch), nil
|
||||
}
|
||||
|
||||
func CompareSemver(left string, right string) (int, error) {
|
||||
leftParts, err := parseSemver(left)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
rightParts, err := parseSemver(right)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
for index := range leftParts {
|
||||
if leftParts[index] > rightParts[index] {
|
||||
return 1, nil
|
||||
}
|
||||
if leftParts[index] < rightParts[index] {
|
||||
return -1, nil
|
||||
}
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func Check(ctx context.Context, options Options) (Result, error) {
|
||||
currentVersion, err := NormalizeVersionTag(options.CurrentVersion)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
repository := strings.TrimSpace(options.Repository)
|
||||
if repository == "" {
|
||||
repository = DefaultRepository
|
||||
}
|
||||
|
||||
endpointValue := options.Endpoint
|
||||
if strings.TrimSpace(endpointValue) == "" {
|
||||
endpointValue = os.Getenv("ZERO_UPDATE_RELEASE_URL")
|
||||
}
|
||||
resolvedEndpoint, err := resolveReleaseEndpoint(endpointValue, repository)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
timeout := options.Timeout
|
||||
if timeout == 0 {
|
||||
timeout = DefaultUpdateCheckTimeout
|
||||
}
|
||||
if timeout > 0 {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, resolvedEndpoint.URL, nil)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
request.Header.Set("Accept", "application/vnd.github+json")
|
||||
request.Header.Set("User-Agent", "zero/"+currentVersion)
|
||||
|
||||
client := options.Client
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
if response.StatusCode < 200 || response.StatusCode > 299 {
|
||||
status := response.Status
|
||||
if strings.TrimSpace(status) == "" {
|
||||
status = strconv.Itoa(response.StatusCode)
|
||||
}
|
||||
if err := response.Body.Close(); err != nil {
|
||||
return Result{}, fmt.Errorf("close release response body: %w", err)
|
||||
}
|
||||
return Result{}, fmt.Errorf("GitHub release check failed (%s)", status)
|
||||
}
|
||||
|
||||
body, readErr := io.ReadAll(response.Body)
|
||||
closeErr := response.Body.Close()
|
||||
if readErr != nil {
|
||||
return Result{}, readErr
|
||||
}
|
||||
if closeErr != nil {
|
||||
return Result{}, fmt.Errorf("close release response body: %w", closeErr)
|
||||
}
|
||||
|
||||
var release githubReleaseResponse
|
||||
if err := json.Unmarshal(body, &release); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
if strings.TrimSpace(release.TagName) == "" {
|
||||
return Result{}, errors.New("GitHub release response did not include a tag_name")
|
||||
}
|
||||
|
||||
latestVersion, err := NormalizeVersionTag(release.TagName)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
comparison, err := CompareSemver(latestVersion, currentVersion)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
releaseURL := strings.TrimSpace(release.HTMLURL)
|
||||
if releaseURL == "" {
|
||||
releaseURL = fmt.Sprintf("https://github.com/%s/releases/tag/%s", resolvedEndpoint.Repository, release.TagName)
|
||||
}
|
||||
|
||||
return Result{
|
||||
CurrentVersion: currentVersion,
|
||||
LatestVersion: latestVersion,
|
||||
ReleaseURL: releaseURL,
|
||||
TagName: release.TagName,
|
||||
UpdateAvailable: comparison > 0,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func Format(result Result) string {
|
||||
if result.UpdateAvailable {
|
||||
return strings.Join([]string{
|
||||
fmt.Sprintf("[zero] Update available: %s -> %s", result.CurrentVersion, result.LatestVersion),
|
||||
"Release: " + result.ReleaseURL,
|
||||
"Download the matching release asset for your platform, then replace the current zero binary.",
|
||||
}, "\n")
|
||||
}
|
||||
|
||||
return strings.Join([]string{
|
||||
fmt.Sprintf("[zero] up to date (%s)", result.CurrentVersion),
|
||||
"Latest release: " + result.ReleaseURL,
|
||||
}, "\n")
|
||||
}
|
||||
|
||||
func resolveReleaseEndpoint(endpointOrRepository string, repository string) (endpointResolution, error) {
|
||||
value := strings.TrimSpace(endpointOrRepository)
|
||||
if value == "" {
|
||||
return endpointResolution{URL: ReleaseEndpoint(repository), Repository: repository}, nil
|
||||
}
|
||||
|
||||
if repositorySlugPattern.MatchString(value) {
|
||||
return endpointResolution{URL: ReleaseEndpoint(value), Repository: value}, nil
|
||||
}
|
||||
|
||||
parsed, err := url.ParseRequestURI(value)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return endpointResolution{}, fmt.Errorf("invalid update endpoint %q. Use a full URL or an owner/repo slug like %s", value, repository)
|
||||
}
|
||||
return endpointResolution{URL: value, Repository: repository}, nil
|
||||
}
|
||||
|
||||
func parseSemver(version string) ([3]uint64, error) {
|
||||
normalized, err := NormalizeVersionTag(version)
|
||||
if err != nil {
|
||||
return [3]uint64{}, err
|
||||
}
|
||||
|
||||
parts := strings.Split(normalized, ".")
|
||||
parsed := [3]uint64{}
|
||||
for index, part := range parts {
|
||||
value, err := parseSemverComponent(part, version)
|
||||
if err != nil {
|
||||
return [3]uint64{}, err
|
||||
}
|
||||
parsed[index] = value
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func parseSemverComponent(component string, originalVersion string) (uint64, error) {
|
||||
value, err := strconv.ParseUint(component, 10, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid semantic version: %s", originalVersion)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
package updatecheck
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type roundTripClient struct {
|
||||
requests []*http.Request
|
||||
response *http.Response
|
||||
err error
|
||||
}
|
||||
|
||||
func (client *roundTripClient) Do(request *http.Request) (*http.Response, error) {
|
||||
client.requests = append(client.requests, request)
|
||||
if client.err != nil {
|
||||
return nil, client.err
|
||||
}
|
||||
return client.response, nil
|
||||
}
|
||||
|
||||
func releaseResponse(statusCode int, status string, body string) *http.Response {
|
||||
return &http.Response{
|
||||
StatusCode: statusCode,
|
||||
Status: status,
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeVersionTag(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"v0.2.0": "0.2.0",
|
||||
"0.2.0": "0.2.0",
|
||||
"v1.2.3+build.4": "1.2.3",
|
||||
"v01.002.0003-a": "1.2.3",
|
||||
}
|
||||
|
||||
for input, want := range tests {
|
||||
got, err := NormalizeVersionTag(input)
|
||||
if err != nil {
|
||||
t.Fatalf("NormalizeVersionTag(%q) returned error: %v", input, err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("NormalizeVersionTag(%q) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeVersionTagRejectsOverflowingComponents(t *testing.T) {
|
||||
_, err := NormalizeVersionTag("v999999999999999999999999999999999999.0.0")
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid semantic version") {
|
||||
t.Fatalf("expected overflow to be rejected as an invalid semantic version, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareSemver(t *testing.T) {
|
||||
assertComparison(t, "0.2.0", "0.1.9", 1)
|
||||
assertComparison(t, "1.0.0", "0.99.99", 1)
|
||||
assertComparison(t, "0.1.1", "0.1.2", -1)
|
||||
assertComparison(t, "v0.1.0", "0.1.0", 0)
|
||||
}
|
||||
|
||||
func TestCheckReportsAvailableUpdate(t *testing.T) {
|
||||
client := &roundTripClient{response: releaseResponse(200, "200 OK", `{
|
||||
"tag_name": "v0.2.0",
|
||||
"html_url": "https://github.com/Gitlawb/zero/releases/tag/v0.2.0"
|
||||
}`)}
|
||||
|
||||
result, err := Check(context.Background(), Options{
|
||||
CurrentVersion: "0.1.0",
|
||||
Client: client,
|
||||
Timeout: -1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Check returned error: %v", err)
|
||||
}
|
||||
|
||||
if result.CurrentVersion != "0.1.0" || result.LatestVersion != "0.2.0" || result.TagName != "v0.2.0" {
|
||||
t.Fatalf("unexpected result versions: %#v", result)
|
||||
}
|
||||
if result.ReleaseURL != "https://github.com/Gitlawb/zero/releases/tag/v0.2.0" {
|
||||
t.Fatalf("ReleaseURL = %q", result.ReleaseURL)
|
||||
}
|
||||
if !result.UpdateAvailable {
|
||||
t.Fatal("expected update to be available")
|
||||
}
|
||||
|
||||
if len(client.requests) != 1 {
|
||||
t.Fatalf("expected 1 request, got %d", len(client.requests))
|
||||
}
|
||||
request := client.requests[0]
|
||||
if request.URL.String() != ReleaseEndpoint(DefaultRepository) {
|
||||
t.Fatalf("request URL = %q, want default release endpoint", request.URL.String())
|
||||
}
|
||||
if got := request.Header.Get("Accept"); got != "application/vnd.github+json" {
|
||||
t.Fatalf("Accept = %q", got)
|
||||
}
|
||||
if got := request.Header.Get("User-Agent"); got != "zero/0.1.0" {
|
||||
t.Fatalf("User-Agent = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckReportsUpToDate(t *testing.T) {
|
||||
client := &roundTripClient{response: releaseResponse(200, "200 OK", `{
|
||||
"tag_name": "v0.2.0",
|
||||
"html_url": "https://github.com/Gitlawb/zero/releases/tag/v0.2.0"
|
||||
}`)}
|
||||
|
||||
result, err := Check(context.Background(), Options{
|
||||
CurrentVersion: "0.2.0",
|
||||
Client: client,
|
||||
Timeout: -1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Check returned error: %v", err)
|
||||
}
|
||||
if result.UpdateAvailable {
|
||||
t.Fatal("expected up-to-date result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckThrowsOnMalformedReleasePayloads(t *testing.T) {
|
||||
client := &roundTripClient{response: releaseResponse(200, "200 OK", `{"name":"Zero 0.2.0"}`)}
|
||||
|
||||
_, err := Check(context.Background(), Options{
|
||||
CurrentVersion: "0.1.0",
|
||||
Client: client,
|
||||
Timeout: -1,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "tag_name") {
|
||||
t.Fatalf("expected tag_name error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckAppliesTimeoutContext(t *testing.T) {
|
||||
client := &roundTripClient{response: releaseResponse(200, "200 OK", `{"tag_name":"v0.1.0"}`)}
|
||||
|
||||
_, err := Check(context.Background(), Options{
|
||||
CurrentVersion: "0.1.0",
|
||||
Client: client,
|
||||
Timeout: 5 * time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Check returned error: %v", err)
|
||||
}
|
||||
if len(client.requests) != 1 {
|
||||
t.Fatalf("expected 1 request, got %d", len(client.requests))
|
||||
}
|
||||
if _, ok := client.requests[0].Context().Deadline(); !ok {
|
||||
t.Fatal("expected request context to have a timeout deadline")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckResolvesEndpointPrecedenceAndRepositorySlugs(t *testing.T) {
|
||||
client := &roundTripClient{response: releaseResponse(200, "200 OK", `{"tag_name":"v0.1.0"}`)}
|
||||
t.Setenv("ZERO_UPDATE_RELEASE_URL", "Gitlawb/env-zero")
|
||||
|
||||
_, err := Check(context.Background(), Options{
|
||||
CurrentVersion: "0.1.0",
|
||||
Endpoint: "Gitlawb/option-zero",
|
||||
Client: client,
|
||||
Timeout: -1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Check with endpoint option returned error: %v", err)
|
||||
}
|
||||
if got := client.requests[len(client.requests)-1].URL.String(); got != ReleaseEndpoint("Gitlawb/option-zero") {
|
||||
t.Fatalf("endpoint option URL = %q", got)
|
||||
}
|
||||
|
||||
client.response = releaseResponse(200, "200 OK", `{"tag_name":"v0.1.0"}`)
|
||||
_, err = Check(context.Background(), Options{
|
||||
CurrentVersion: "0.1.0",
|
||||
Repository: "Gitlawb/repo-zero",
|
||||
Client: client,
|
||||
Timeout: -1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Check with env endpoint returned error: %v", err)
|
||||
}
|
||||
if got := client.requests[len(client.requests)-1].URL.String(); got != ReleaseEndpoint("Gitlawb/env-zero") {
|
||||
t.Fatalf("env endpoint URL = %q", got)
|
||||
}
|
||||
|
||||
t.Setenv("ZERO_UPDATE_RELEASE_URL", "")
|
||||
client.response = releaseResponse(200, "200 OK", `{"tag_name":"v0.1.0"}`)
|
||||
_, err = Check(context.Background(), Options{
|
||||
CurrentVersion: "0.1.0",
|
||||
Repository: "Gitlawb/repo-zero",
|
||||
Client: client,
|
||||
Timeout: -1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Check with default repository returned error: %v", err)
|
||||
}
|
||||
if got := client.requests[len(client.requests)-1].URL.String(); got != ReleaseEndpoint("Gitlawb/repo-zero") {
|
||||
t.Fatalf("repository endpoint URL = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveReleaseEndpointAcceptsFullURLsAndRejectsInvalidValues(t *testing.T) {
|
||||
got, err := ResolveReleaseEndpoint("https://example.test/latest", "Gitlawb/zero")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveReleaseEndpoint full URL returned error: %v", err)
|
||||
}
|
||||
if got != "https://example.test/latest" {
|
||||
t.Fatalf("full URL = %q", got)
|
||||
}
|
||||
|
||||
got, err = ResolveReleaseEndpoint("Gitlawb/zero", "Fallback/repo")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveReleaseEndpoint slug returned error: %v", err)
|
||||
}
|
||||
if got != ReleaseEndpoint("Gitlawb/zero") {
|
||||
t.Fatalf("slug URL = %q", got)
|
||||
}
|
||||
|
||||
_, err = ResolveReleaseEndpoint("not-a-url", "Gitlawb/zero")
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid update endpoint") {
|
||||
t.Fatalf("expected invalid endpoint error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormat(t *testing.T) {
|
||||
output := Format(Result{
|
||||
CurrentVersion: "0.1.0",
|
||||
LatestVersion: "0.2.0",
|
||||
ReleaseURL: "https://github.com/Gitlawb/zero/releases/tag/v0.2.0",
|
||||
TagName: "v0.2.0",
|
||||
UpdateAvailable: true,
|
||||
})
|
||||
|
||||
if !strings.Contains(output, "Update available: 0.1.0 -> 0.2.0") {
|
||||
t.Fatalf("expected update text, got %q", output)
|
||||
}
|
||||
}
|
||||
|
||||
func assertComparison(t *testing.T, left string, right string, want int) {
|
||||
t.Helper()
|
||||
got, err := CompareSemver(left, right)
|
||||
if err != nil {
|
||||
t.Fatalf("CompareSemver(%q, %q) returned error: %v", left, right, err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("CompareSemver(%q, %q) = %d, want %d", left, right, got, want)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user