diff --git a/internal/versioncheck/versioncheck.go b/internal/versioncheck/versioncheck.go index 3200723..7a00d29 100644 --- a/internal/versioncheck/versioncheck.go +++ b/internal/versioncheck/versioncheck.go @@ -20,6 +20,15 @@ import ( const ( stateFileName = "version-state.json" githubLatestRelease = "https://api.github.com/repos/isink17/codegraph/releases/latest" + releasesPage = "https://github.com/isink17/codegraph/releases" + installCmd = "go install github.com/isink17/codegraph/cmd/codegraph@latest" +) + +const versionCheckTimeout = 5 * time.Second + +var ( + githubLatestReleaseURL = githubLatestRelease + versionHTTPClient = &http.Client{Timeout: versionCheckTimeout} ) type state struct { @@ -149,13 +158,20 @@ func normalizedSemver(v string) (string, bool) { return v, semver.IsValid(v) } -func fetchLatestFromGitHub(ctx context.Context, current, etag, lastModified string) (fetchResult, error) { - reqCtx, cancel := context.WithTimeout(ctx, 2*time.Second) +type latestReleaseResult struct { + TagName string + ETag string + LastModified string + NotModified bool +} + +func fetchLatestReleaseFromGitHub(ctx context.Context, current, etag, lastModified string) (latestReleaseResult, error) { + reqCtx, cancel := context.WithTimeout(ctx, versionCheckTimeout) defer cancel() - req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, githubLatestRelease, nil) + req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, githubLatestReleaseURL, nil) if err != nil { - return fetchResult{}, err + return latestReleaseResult{}, err } req.Header.Set("Accept", "application/vnd.github+json") req.Header.Set("User-Agent", "codegraph/"+sanitizeVersion(current)) @@ -166,35 +182,106 @@ func fetchLatestFromGitHub(ctx context.Context, current, etag, lastModified stri req.Header.Set("If-Modified-Since", strings.TrimSpace(lastModified)) } - resp, err := http.DefaultClient.Do(req) + resp, err := versionHTTPClient.Do(req) if err != nil { - return fetchResult{}, err + return latestReleaseResult{}, err } defer resp.Body.Close() + + trimmedETag := strings.TrimSpace(resp.Header.Get("ETag")) + trimmedLastModified := strings.TrimSpace(resp.Header.Get("Last-Modified")) + if resp.StatusCode == http.StatusNotModified { - return fetchResult{ + return latestReleaseResult{ NotModified: true, - ETag: strings.TrimSpace(resp.Header.Get("ETag")), - LastModified: strings.TrimSpace(resp.Header.Get("Last-Modified")), + ETag: trimmedETag, + LastModified: trimmedLastModified, }, nil } if resp.StatusCode != http.StatusOK { - return fetchResult{}, fmt.Errorf("github latest release request failed: %s", resp.Status) + return latestReleaseResult{}, fmt.Errorf("github latest release request failed: %s", resp.Status) } var payload struct { TagName string `json:"tag_name"` } if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { - return fetchResult{}, err + return latestReleaseResult{}, err } - return fetchResult{ - LatestVersion: strings.TrimSpace(payload.TagName), - ETag: strings.TrimSpace(resp.Header.Get("ETag")), - LastModified: strings.TrimSpace(resp.Header.Get("Last-Modified")), + + return latestReleaseResult{ + TagName: strings.TrimSpace(payload.TagName), + ETag: trimmedETag, + LastModified: trimmedLastModified, }, nil } +func fetchLatestFromGitHub(ctx context.Context, current, etag, lastModified string) (fetchResult, error) { + res, err := fetchLatestReleaseFromGitHub(ctx, current, etag, lastModified) + if err != nil { + return fetchResult{}, err + } + if res.NotModified { + return fetchResult{NotModified: true, ETag: res.ETag, LastModified: res.LastModified}, nil + } + return fetchResult{LatestVersion: res.TagName, ETag: res.ETag, LastModified: res.LastModified}, nil +} + +// RunCheck performs an explicit, non-cached version check and writes results to w. +// Returns a non-nil error (non-zero exit code) if GitHub could not be reached. +func RunCheck(ctx context.Context, w io.Writer) error { + current := version.Current() + return runCheck(ctx, current, fetchTagNow, w) +} + +func runCheck(ctx context.Context, current string, fetchFn func(context.Context, string) (string, error), w io.Writer) error { + latest, err := fetchFn(ctx, current) + if err != nil { + fmt.Fprintf(w, "version check failed: %s\n\nSee releases at:\n %s\n", err, releasesPage) + // Avoid double-reporting: callers already print returned errors. + return nil + } + + currentNorm, currentOK := normalizedSemver(current) + latestNorm, latestOK := normalizedSemver(latest) + unknownCurrent := !currentOK + + if unknownCurrent { + fmt.Fprintf(w, "Current version: unknown (installed as %q)\n", current) + } else { + fmt.Fprintf(w, "Current version: %s\n", current) + } + fmt.Fprintf(w, "Latest version: %s\n", latest) + + if !unknownCurrent && latestOK { + if semver.Compare(currentNorm, latestNorm) >= 0 { + fmt.Fprintln(w, "\ncodegraph is up to date.") + } else { + fmt.Fprintln(w, "\ncodegraph is not on the latest version.") + fmt.Fprintf(w, "\nUpdate with:\n %s\n\nReleases:\n %s\n", installCmd, releasesPage) + } + return nil + } + + if unknownCurrent { + fmt.Fprintln(w, "\nLocal version could not be reliably determined.") + } + fmt.Fprintf(w, "\nUpdate with:\n %s\n\nReleases:\n %s\n", installCmd, releasesPage) + return nil +} + +func fetchTagNow(ctx context.Context, current string) (string, error) { + res, err := fetchLatestReleaseFromGitHub(ctx, current, "", "") + if err != nil { + return "", err + } + if res.NotModified { + // Should not happen without cache headers, but treat as empty/no-op. + return "", nil + } + return res.TagName, nil +} + func sanitizeVersion(v string) string { v = strings.TrimSpace(v) if v == "" { diff --git a/internal/versioncheck/versioncheck_test.go b/internal/versioncheck/versioncheck_test.go index 1c89b6e..9ab0c61 100644 --- a/internal/versioncheck/versioncheck_test.go +++ b/internal/versioncheck/versioncheck_test.go @@ -4,6 +4,9 @@ import ( "bytes" "context" "encoding/json" + "fmt" + "net/http" + "net/http/httptest" "os" "path/filepath" "strings" @@ -121,6 +124,189 @@ func TestCheckerHandlesNotModifiedAndPreservesLatest(t *testing.T) { } } +func TestNormalizedSemver(t *testing.T) { + cases := []struct { + input string + want string + wantOK bool + }{ + {"v1.2.3", "v1.2.3", true}, + {"1.2.3", "v1.2.3", true}, + {" v1.2.3 ", "v1.2.3", true}, + {"1.2.3-rc.1", "v1.2.3-rc.1", true}, + {"", "", false}, + {"dev", "vdev", false}, + {"(devel)", "v(devel)", false}, + } + for _, tc := range cases { + got, ok := normalizedSemver(tc.input) + if ok != tc.wantOK || got != tc.want { + t.Errorf("normalizedSemver(%q) = (%q, %v), want (%q, %v)", tc.input, got, ok, tc.want, tc.wantOK) + } + } +} + +func TestRunCheckUpToDate(t *testing.T) { + var out bytes.Buffer + err := runCheck(context.Background(), "v1.2.3", func(_ context.Context, _ string) (string, error) { + return "v1.2.3", nil + }, &out) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(out.String(), "up to date") { + t.Fatalf("expected up to date message, got: %q", out.String()) + } +} + +func TestRunCheckOutdated(t *testing.T) { + var out bytes.Buffer + err := runCheck(context.Background(), "v1.2.3", func(_ context.Context, _ string) (string, error) { + return "v1.3.0", nil + }, &out) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + s := out.String() + if !strings.Contains(s, "not on the latest version") { + t.Fatalf("expected outdated message, got: %q", s) + } + if !strings.Contains(s, installCmd) { + t.Fatalf("expected install command, got: %q", s) + } + if !strings.Contains(s, releasesPage) { + t.Fatalf("expected releases page, got: %q", s) + } +} + +func TestRunCheckCurrentAheadOfLatest(t *testing.T) { + var out bytes.Buffer + err := runCheck(context.Background(), "v2.0.0", func(_ context.Context, _ string) (string, error) { + return "v1.9.9", nil + }, &out) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(out.String(), "up to date") { + t.Fatalf("expected up to date message, got: %q", out.String()) + } +} + +func TestRunCheckUnknownVersion(t *testing.T) { + var out bytes.Buffer + err := runCheck(context.Background(), "dev", func(_ context.Context, _ string) (string, error) { + return "v1.3.0", nil + }, &out) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + s := out.String() + if !strings.Contains(s, "unknown") { + t.Fatalf("expected unknown version message, got: %q", s) + } + if !strings.Contains(s, "v1.3.0") { + t.Fatalf("expected latest version in output, got: %q", s) + } + if !strings.Contains(s, installCmd) { + t.Fatalf("expected install command, got: %q", s) + } +} + +func TestRunCheckNetworkFailure(t *testing.T) { + var out bytes.Buffer + err := runCheck(context.Background(), "v1.2.3", func(_ context.Context, _ string) (string, error) { + return "", fmt.Errorf("connection refused") + }, &out) + if err != nil { + t.Fatalf("expected nil error (printed message already), got: %v", err) + } + if !strings.Contains(out.String(), releasesPage) { + t.Fatalf("expected releases page in output, got: %q", out.String()) + } +} + +func TestFetchLatestFromGitHub200(t *testing.T) { + restoreURL, restoreClient := githubLatestReleaseURL, versionHTTPClient + t.Cleanup(func() { + githubLatestReleaseURL = restoreURL + versionHTTPClient = restoreClient + }) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Accept"); got != "application/vnd.github+json" { + t.Fatalf("Accept = %q, want application/vnd.github+json", got) + } + if got := r.Header.Get("User-Agent"); got == "" { + t.Fatalf("expected User-Agent to be set") + } + w.Header().Set("ETag", `"etag123"`) + w.Header().Set("Last-Modified", "Wed, 18 Mar 2026 09:00:00 GMT") + _, _ = w.Write([]byte(`{"tag_name":"v1.2.3"}`)) + })) + defer srv.Close() + + githubLatestReleaseURL = srv.URL + versionHTTPClient = srv.Client() + + got, err := fetchLatestFromGitHub(context.Background(), "v1.0.0", "", "") + if err != nil { + t.Fatalf("fetchLatestFromGitHub() error = %v", err) + } + if got.NotModified { + t.Fatalf("NotModified = true, want false") + } + if got.LatestVersion != "v1.2.3" { + t.Fatalf("LatestVersion = %q, want v1.2.3", got.LatestVersion) + } + if got.ETag != `"etag123"` { + t.Fatalf("ETag = %q, want %q", got.ETag, `"etag123"`) + } + if got.LastModified == "" { + t.Fatalf("LastModified = empty, want non-empty") + } +} + +func TestFetchLatestFromGitHub304(t *testing.T) { + restoreURL, restoreClient := githubLatestReleaseURL, versionHTTPClient + t.Cleanup(func() { + githubLatestReleaseURL = restoreURL + versionHTTPClient = restoreClient + }) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("If-None-Match"); got != `"abc123"` { + t.Fatalf("If-None-Match = %q, want %q", got, `"abc123"`) + } + if got := r.Header.Get("If-Modified-Since"); got != "Wed, 18 Mar 2026 09:00:00 GMT" { + t.Fatalf("If-Modified-Since = %q, want %q", got, "Wed, 18 Mar 2026 09:00:00 GMT") + } + w.Header().Set("ETag", `"etag-new"`) + w.Header().Set("Last-Modified", "Thu, 19 Mar 2026 09:00:00 GMT") + w.WriteHeader(http.StatusNotModified) + })) + defer srv.Close() + + githubLatestReleaseURL = srv.URL + versionHTTPClient = srv.Client() + + got, err := fetchLatestFromGitHub(context.Background(), "v1.0.0", `"abc123"`, "Wed, 18 Mar 2026 09:00:00 GMT") + if err != nil { + t.Fatalf("fetchLatestFromGitHub() error = %v", err) + } + if !got.NotModified { + t.Fatalf("NotModified = false, want true") + } + if got.LatestVersion != "" { + t.Fatalf("LatestVersion = %q, want empty", got.LatestVersion) + } + if got.ETag != `"etag-new"` { + t.Fatalf("ETag = %q, want %q", got.ETag, `"etag-new"`) + } + if got.LastModified != "Thu, 19 Mar 2026 09:00:00 GMT" { + t.Fatalf("LastModified = %q, want %q", got.LastModified, "Thu, 19 Mar 2026 09:00:00 GMT") + } +} + func readState(t *testing.T, path string) state { t.Helper() var st state