Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 102 additions & 15 deletions internal/versioncheck/versioncheck.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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))
Expand All @@ -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
}
Comment thread
isink17 marked this conversation as resolved.

func sanitizeVersion(v string) string {
v = strings.TrimSpace(v)
if v == "" {
Expand Down
186 changes: 186 additions & 0 deletions internal/versioncheck/versioncheck_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
Expand Down Expand Up @@ -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
Expand Down
Loading