From f415e2ed26558f219a32e42d983d3f1f7af867fc Mon Sep 17 00:00:00 2001 From: isink17 <39876158+isink17@users.noreply.github.com> Date: Wed, 6 May 2026 07:36:58 +0200 Subject: [PATCH 1/3] implement checking for version --- internal/versioncheck/versioncheck.go | 70 ++++++++++++++ internal/versioncheck/versioncheck_test.go | 102 +++++++++++++++++++++ 2 files changed, 172 insertions(+) diff --git a/internal/versioncheck/versioncheck.go b/internal/versioncheck/versioncheck.go index 3200723..4bc170e 100644 --- a/internal/versioncheck/versioncheck.go +++ b/internal/versioncheck/versioncheck.go @@ -20,6 +20,8 @@ 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" ) type state struct { @@ -195,6 +197,74 @@ func fetchLatestFromGitHub(ctx context.Context, current, etag, lastModified stri }, 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) + return fmt.Errorf("version check: %w", err) + } + + currentNorm, currentOK := normalizedSemver(current) + latestNorm, latestOK := normalizedSemver(latest) + unknownCurrent := !currentOK || strings.TrimSpace(current) == "dev" + + 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) { + reqCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, githubLatestRelease, nil) + if err != nil { + return "", err + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("User-Agent", "codegraph-version-check/"+sanitizeVersion(current)) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("github API returned %s", resp.Status) + } + var payload struct { + TagName string `json:"tag_name"` + } + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return "", err + } + return strings.TrimSpace(payload.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..8871999 100644 --- a/internal/versioncheck/versioncheck_test.go +++ b/internal/versioncheck/versioncheck_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "fmt" "os" "path/filepath" "strings" @@ -121,6 +122,107 @@ 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.Fatal("expected error on network failure") + } + if !strings.Contains(out.String(), releasesPage) { + t.Fatalf("expected releases page in output, got: %q", out.String()) + } +} + func readState(t *testing.T, path string) state { t.Helper() var st state From 39e6265bbb0396be1f007801cfd501c77fba1dde Mon Sep 17 00:00:00 2001 From: isink <39876158+isink17@users.noreply.github.com> Date: Wed, 6 May 2026 07:57:34 +0200 Subject: [PATCH 2/3] Update internal/versioncheck/versioncheck.go Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- internal/versioncheck/versioncheck.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/versioncheck/versioncheck.go b/internal/versioncheck/versioncheck.go index 4bc170e..27d1000 100644 --- a/internal/versioncheck/versioncheck.go +++ b/internal/versioncheck/versioncheck.go @@ -213,7 +213,7 @@ func runCheck(ctx context.Context, current string, fetchFn func(context.Context, currentNorm, currentOK := normalizedSemver(current) latestNorm, latestOK := normalizedSemver(latest) - unknownCurrent := !currentOK || strings.TrimSpace(current) == "dev" + unknownCurrent := !currentOK if unknownCurrent { fmt.Fprintf(w, "Current version: unknown (installed as %q)\n", current) From fa74a3325d1d24e21a72e12ef63e04479a3768ba Mon Sep 17 00:00:00 2001 From: isink17 <39876158+isink17@users.noreply.github.com> Date: Wed, 6 May 2026 08:04:00 +0200 Subject: [PATCH 3/3] MR fixes --- internal/versioncheck/versioncheck.go | 87 ++++++++++++--------- internal/versioncheck/versioncheck_test.go | 88 +++++++++++++++++++++- 2 files changed, 138 insertions(+), 37 deletions(-) diff --git a/internal/versioncheck/versioncheck.go b/internal/versioncheck/versioncheck.go index 27d1000..7a00d29 100644 --- a/internal/versioncheck/versioncheck.go +++ b/internal/versioncheck/versioncheck.go @@ -24,6 +24,13 @@ const ( 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 { CurrentVersion string `json:"current_version"` LatestVersion string `json:"latest_version,omitempty"` @@ -151,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)) @@ -168,35 +182,51 @@ 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 { @@ -208,7 +238,8 @@ func runCheck(ctx context.Context, current string, fetchFn func(context.Context, latest, err := fetchFn(ctx, current) if err != nil { fmt.Fprintf(w, "version check failed: %s\n\nSee releases at:\n %s\n", err, releasesPage) - return fmt.Errorf("version check: %w", err) + // Avoid double-reporting: callers already print returned errors. + return nil } currentNorm, currentOK := normalizedSemver(current) @@ -240,29 +271,15 @@ func runCheck(ctx context.Context, current string, fetchFn func(context.Context, } func fetchTagNow(ctx context.Context, current string) (string, error) { - reqCtx, cancel := context.WithTimeout(ctx, 5*time.Second) - defer cancel() - req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, githubLatestRelease, nil) - if err != nil { - return "", err - } - req.Header.Set("Accept", "application/vnd.github+json") - req.Header.Set("User-Agent", "codegraph-version-check/"+sanitizeVersion(current)) - resp, err := http.DefaultClient.Do(req) + res, err := fetchLatestReleaseFromGitHub(ctx, current, "", "") if err != nil { return "", err } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("github API returned %s", resp.Status) - } - var payload struct { - TagName string `json:"tag_name"` - } - if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { - return "", err + if res.NotModified { + // Should not happen without cache headers, but treat as empty/no-op. + return "", nil } - return strings.TrimSpace(payload.TagName), nil + return res.TagName, nil } func sanitizeVersion(v string) string { diff --git a/internal/versioncheck/versioncheck_test.go b/internal/versioncheck/versioncheck_test.go index 8871999..9ab0c61 100644 --- a/internal/versioncheck/versioncheck_test.go +++ b/internal/versioncheck/versioncheck_test.go @@ -5,6 +5,8 @@ import ( "context" "encoding/json" "fmt" + "net/http" + "net/http/httptest" "os" "path/filepath" "strings" @@ -215,14 +217,96 @@ func TestRunCheckNetworkFailure(t *testing.T) { err := runCheck(context.Background(), "v1.2.3", func(_ context.Context, _ string) (string, error) { return "", fmt.Errorf("connection refused") }, &out) - if err == nil { - t.Fatal("expected error on network failure") + 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