Skip to content

Add background update notifier#312

Open
kindermax wants to merge 2 commits intomasterfrom
codex/adapt-cli-update-plan-to
Open

Add background update notifier#312
kindermax wants to merge 2 commits intomasterfrom
codex/adapt-cli-update-plan-to

Conversation

@kindermax
Copy link
Collaborator

@kindermax kindermax commented Mar 19, 2026

  • Summary
    • add an upgrade notifier that fetches GH release info once per day, skips non-interactive/CI sessions, and respects opt-out + Homebrew timing
    • integrate the notifier into internal/cli to run in the background and print upgrade hints after commands finish
    • extend registry/util test coverage and document the new LETS_NO_UPDATE_NOTIFIER variable in docs
  • Testing
    • Not run (not requested)

Summary by Sourcery

Add an opt-outable background update notification system backed by GitHub release metadata and cached state, integrating it into the CLI for interactive sessions while updating docs and tests.

New Features:

  • Add a background update notifier that checks GitHub releases once per day for interactive CLI sessions and prints upgrade hints after commands complete.

Enhancements:

  • Introduce an update state file and Homebrew-aware logic to avoid noisy or premature upgrade prompts, including suppression for dev builds and recent releases.
  • Extend the GitHub registry client to expose structured latest-release metadata via the GitHub API and improve version parsing to handle leading 'v' prefixes.

Documentation:

  • Document the new LETS_NO_UPDATE_NOTIFIER environment variable and note background update notifications in the changelog.

Tests:

  • Add unit tests for the update notifier behavior, GitHub registry latest-release retrieval, and CLI update-check gating rules.

greptile-apps[bot]

This comment was marked as off-topic.

@sourcery-ai
Copy link

sourcery-ai bot commented Mar 19, 2026

Reviewer's Guide

Introduce a background update notifier that asynchronously checks GitHub releases for interactive CLI sessions, persists state to avoid frequent checks, and emits Homebrew-aware upgrade hints after command execution, while extending the registry API, semver parsing, tests, and documentation.

Sequence diagram for background update notifier during CLI command

sequenceDiagram
    actor User
    participant CLI as internal_cli_Main
    participant Cmd as cobra_Command
    participant Notifier as upgrade_UpdateNotifier
    participant Registry as upgrade_registry_GithubRegistry
    participant GH as GitHub_API

    User->>CLI: start lets (version)
    CLI->>CLI: maybeStartUpdateCheck(ctx, version, rootCmd)
    CLI->>CLI: shouldCheckForUpdate(commandName, isInteractiveStderr)
    alt skip_update_check
        CLI->>Cmd: ExecuteContext(ctx)
        Cmd-->>CLI: result
        CLI->>CLI: printUpdateNotice(nil)
        CLI-->>User: command output only
    else start_background_check
        CLI->>Registry: NewGithubRegistry(ctx)
        CLI->>Notifier: NewUpdateNotifier(Registry)
        CLI->>Notifier: Check(checkCtx, version) async
        activate Notifier
        Notifier->>Registry: GetLatestReleaseInfo(checkCtx)
        Registry->>GH: GET /repos/lets-cli/lets/releases/latest
        GH-->>Registry: ReleaseInfo(tag_name, published_at)
        Registry-->>Notifier: ReleaseInfo
        Notifier->>Notifier: readState()
        Notifier->>Notifier: noticeFromState(...)
        Notifier-->>CLI: updateCheckResult(notice)
        deactivate Notifier
        CLI->>Cmd: ExecuteContext(ctx)
        Cmd-->>CLI: result
        CLI->>CLI: printUpdateNotice(updateCh)
        alt notice_available
            CLI->>User: stderr: notice.Message()
            CLI->>Notifier: MarkNotified(notice)
            Notifier->>Notifier: writeState(updated_state)
        else no_notice_or_timeout
            CLI->>User: no update message
        end
    end
Loading

Updated class diagram for upgrade notifier and registry types

classDiagram
    class upgrade_UpdateNotice {
        +string CurrentVersion
        +string LatestVersion
        -string command
        +Message() string
    }

    class upgrade_notifierState {
        +time_Time CheckedAt
        +string LatestVersion
        +time_Time LatestPublishedAt
        +time_Time NotifiedAt
    }

    class upgrade_UpdateNotifier {
        -registry_RepoRegistry registry
        -string statePath
        -string executablePath
        -func() time_Time now
        +Check(ctx context_Context, currentVersion string) (*upgrade_UpdateNotice, error)
        +MarkNotified(notice *upgrade_UpdateNotice) error
        -noticeFromState(state upgrade_notifierState, currentVersion string, current *semver_Version, now time_Time) *upgrade_UpdateNotice
        -readState() (upgrade_notifierState, error)
        -writeState(state upgrade_notifierState) error
    }

    class upgrade_registry_RepoRegistry {
        <<interface>>
        +GetLatestReleaseInfo(ctx context_Context) (*upgrade_registry_ReleaseInfo, error)
        +GetLatestRelease() (string, error)
        +DownloadReleaseBinary(packageName string, version string, dstPath string) error
        +GetPackageName(os string, arch string) (string, error)
    }

    class upgrade_registry_GithubRegistry {
        -http_Client *http_Client
        -context_Context ctx
        -string repoURI
        -string apiURI
        -string downloadURL
        -time_Duration downloadPackageTimeout
        -time_Duration latestReleaseTimeout
        +NewGithubRegistry(ctx context_Context) *upgrade_registry_GithubRegistry
        +GetLatestReleaseInfo(ctx context_Context) (*upgrade_registry_ReleaseInfo, error)
        +GetLatestRelease() (string, error)
        +DownloadReleaseBinary(packageName string, version string, dstPath string) error
        +GetPackageName(os string, arch string) (string, error)
    }

    class upgrade_registry_ReleaseInfo {
        +string TagName
        +time_Time PublishedAt
    }

    class util_VersionHelpers {
        +ParseVersion(version string) (*semver_Version, error)
    }

    upgrade_UpdateNotifier --> upgrade_notifierState : uses
    upgrade_UpdateNotifier --> upgrade_UpdateNotice : creates
    upgrade_UpdateNotifier --> upgrade_registry_RepoRegistry : depends_on
    upgrade_registry_GithubRegistry ..|> upgrade_registry_RepoRegistry : implements
    upgrade_registry_GithubRegistry --> upgrade_registry_ReleaseInfo : returns
    upgrade_UpdateNotifier --> upgrade_registry_ReleaseInfo : reads
    upgrade_UpdateNotifier --> util_VersionHelpers : uses
Loading

Flow diagram for deciding when to show an update notice

flowchart TD
    A[start Check called with currentVersion] --> B[parseStableVersion currentVersion]
    B -->|invalid_or_prerelease| Z1[return nil notice]
    B -->|stable| C[readState from YAML]

    C --> D[now minus state.CheckedAt < updateCheckInterval?]
    D -->|yes| E[noticeFromState using cached LatestVersion]
    D -->|no| F[GetLatestReleaseInfo from registry]
    F -->|error| E
    F -->|success| G[update state.CheckedAt, LatestVersion, LatestPublishedAt]
    G --> H[writeState]
    H --> E

    E --> I[parseStableVersion state.LatestVersion]
    I -->|invalid| Z2[return nil notice]
    I -->|valid| J[current < latest?]
    J -->|no| Z3[return nil notice]
    J -->|yes| K[now minus state.NotifiedAt < updateNotifyInterval?]
    K -->|yes| Z4[return nil notice]
    K -->|no| L[isHomebrewInstall executablePath?]

    L -->|no| M[command = lets --upgrade]
    L -->|yes| N[now minus state.LatestPublishedAt < homebrewNoticeDelay?]
    N -->|yes| Z5[return nil notice]
    N -->|no| O[command = brew upgrade lets-cli/tap/lets]

    M --> P[create UpdateNotice]
    O --> P
    P --> Z6[return notice]
Loading

File-Level Changes

Change Details Files
Run background update checks for interactive CLI sessions and print notices after commands complete.
  • Add updateCheckResult struct and updateCheckTimeout to coordinate async checks
  • Start maybeStartUpdateCheck before executing the root command and defer cancelation
  • After command execution, non-blockingly read from the update channel and print any update notice to stderr, then mark it as notified
  • Gate update checks behind shouldCheckForUpdate, which skips non-interactive, CI, opt-out env, and internal commands, and uses isInteractiveStderr via go-isatty
internal/cli/cli.go
internal/cli/cli_test.go
Add an UpdateNotifier that caches release info on disk, respects intervals, and tailors messages for Homebrew installs.
  • Introduce UpdateNotifier with Check and MarkNotified methods that read/write YAML state under the user config dir
  • Implement notifierState persistence with atomic temp-file replacement and configurable clock injection for tests
  • Only emit notices for stable (non-prerelease) semver versions newer than current, with daily check/notify intervals
  • Detect Homebrew installs via binary path and delay Homebrew upgrade notices until the GitHub release has aged past a threshold
  • Provide UpdateNotice type with formatted message and LogUpdateCheckError helper
internal/upgrade/notifier.go
internal/upgrade/notifier_test.go
Extend the upgrade registry to expose detailed GitHub release info via the GitHub API.
  • Add ReleaseInfo struct (tag and published_at) and GetLatestReleaseInfo to the RepoRegistry interface
  • Update GithubRegistry to store a separate apiURI, call the GitHub API /releases/latest endpoint, set appropriate headers, and validate HTTP status codes
  • Refactor GetLatestRelease to delegate to GetLatestReleaseInfo, returning only the tag name
  • Add tests using httptest servers to validate request path, headers, and response parsing
internal/upgrade/registry/registry.go
internal/upgrade/registry/registry_test.go
internal/upgrade/upgrade_test.go
Relax version parsing to accept v-prefixed versions and document the new environment opt-out and changelog entry.
  • Trim a leading 'v' from version strings before semver parsing to align with GitHub tag formats
  • Update env documentation to describe LETS_NO_UPDATE_NOTIFIER as an opt-out for background update checks
  • Add a changelog entry describing background update notifications and the new env var
internal/util/version.go
docs/docs/env.md
docs/docs/changelog.md

Possibly linked issues

  • Version in config #46: The PR implements background update checks, notifications, Homebrew detection, and LETS_NO_UPDATE_NOTIFIER opt-out exactly as requested in the issue.

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 2 issues, and left some high level feedback:

  • In UpdateNotifier.readState, decoding an empty or partially written state file will return an error and disable notifications until the file is manually fixed; consider treating io.EOF (or a zero-length file) as an empty state instead of an error to make the state format more robust across versions.
  • The Homebrew detection in isHomebrewInstall relies on a hard-coded "/Cellar/lets/" path fragment; if the formula name or layout changes (e.g., different tap/name), this will silently fall back to the generic upgrade command—consider either making this check more flexible or scoping the string match to the tap path you expect (e.g., lets-cli/tap).
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `UpdateNotifier.readState`, decoding an empty or partially written state file will return an error and disable notifications until the file is manually fixed; consider treating `io.EOF` (or a zero-length file) as an empty state instead of an error to make the state format more robust across versions.
- The Homebrew detection in `isHomebrewInstall` relies on a hard-coded `"/Cellar/lets/"` path fragment; if the formula name or layout changes (e.g., different tap/name), this will silently fall back to the generic upgrade command—consider either making this check more flexible or scoping the string match to the tap path you expect (e.g., `lets-cli/tap`).

## Individual Comments

### Comment 1
<location path="internal/cli/cli.go" line_range="218-227" />
<code_context>
+	return ch, cancel
+}
+
+func printUpdateNotice(updateCh <-chan updateCheckResult) {
+	if updateCh == nil {
+		return
+	}
+
+	select {
+	case result := <-updateCh:
+		if result.notice == nil {
+			return
+		}
+
+		if _, err := fmt.Fprintln(os.Stderr, result.notice.Message()); err != nil {
+			log.Debugf("lets: update notifier print failed: %s", err)
+			return
+		}
+
+		if err := result.notifier.MarkNotified(result.notice); err != nil {
+			upgrade.LogUpdateCheckError(err)
+		}
+	default:
+	}
+}
</code_context>
<issue_to_address>
**issue (bug_risk):** Non-blocking receive can easily skip update notices and leaves the goroutine potentially blocked on send.

Because of the `select { case <-updateCh: ... default: }`, if `printUpdateNotice` runs before the goroutine sends to `updateCh`, the default branch is taken and the notice is never printed, even if the check succeeds shortly after. If the send happens after `printUpdateNotice` returns, the goroutine will block forever on `ch <- updateCheckResult`. Consider either using a blocking receive here (the check already has a timeout) or making the goroutine’s send non-blocking and treating the update as best-effort so neither side can block indefinitely and the notice is printed once the check completes.
</issue_to_address>

### Comment 2
<location path="internal/upgrade/notifier.go" line_range="181" />
<code_context>
+
+	defer file.Close()
+
+	if err := yaml.NewDecoder(file).Decode(&state); err != nil {
+		return notifierState{}, fmt.Errorf("failed to decode update state file: %w", err)
+	}
</code_context>
<issue_to_address>
**suggestion:** YAML decode errors on the state file permanently break update checks instead of treating the state as empty.

If the state file is corrupted or partially written, `readState` will keep returning an error and `Check` will surface it on every run. Consider treating decode errors the same as a missing/empty state (return a zero `notifierState` and proceed, with a debug log) so a bad file doesn’t permanently disable update checks.

Suggested implementation:

```golang
	if err := yaml.NewDecoder(file).Decode(&state); err != nil {
		// Treat a corrupted/partial state file as empty state so update checks can continue.
		// This avoids permanently breaking update checks due to a bad state file.
		log.Printf("debug: failed to decode update state file, ignoring and treating as empty: %v", err)
		return notifierState{}, nil
	}

```

1. Ensure `internal/upgrade/notifier.go` imports the standard library `log` package:
   - Add `import "log"` to the existing import block if it is not already present.
2. If the project uses a structured logging abstraction instead of the standard library logger, replace the `log.Printf(...)` call with the appropriate debug-level logging call that matches the rest of the codebase (for example, `logger.Debug(...)` or similar).
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +218 to +227
func printUpdateNotice(updateCh <-chan updateCheckResult) {
if updateCh == nil {
return
}

select {
case result := <-updateCh:
if result.notice == nil {
return
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): Non-blocking receive can easily skip update notices and leaves the goroutine potentially blocked on send.

Because of the select { case <-updateCh: ... default: }, if printUpdateNotice runs before the goroutine sends to updateCh, the default branch is taken and the notice is never printed, even if the check succeeds shortly after. If the send happens after printUpdateNotice returns, the goroutine will block forever on ch <- updateCheckResult. Consider either using a blocking receive here (the check already has a timeout) or making the goroutine’s send non-blocking and treating the update as best-effort so neither side can block indefinitely and the notice is printed once the check completes.


defer file.Close()

if err := yaml.NewDecoder(file).Decode(&state); err != nil {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: YAML decode errors on the state file permanently break update checks instead of treating the state as empty.

If the state file is corrupted or partially written, readState will keep returning an error and Check will surface it on every run. Consider treating decode errors the same as a missing/empty state (return a zero notifierState and proceed, with a debug log) so a bad file doesn’t permanently disable update checks.

Suggested implementation:

	if err := yaml.NewDecoder(file).Decode(&state); err != nil {
		// Treat a corrupted/partial state file as empty state so update checks can continue.
		// This avoids permanently breaking update checks due to a bad state file.
		log.Printf("debug: failed to decode update state file, ignoring and treating as empty: %v", err)
		return notifierState{}, nil
	}
  1. Ensure internal/upgrade/notifier.go imports the standard library log package:
    • Add import "log" to the existing import block if it is not already present.
  2. If the project uses a structured logging abstraction instead of the standard library logger, replace the log.Printf(...) call with the appropriate debug-level logging call that matches the rest of the codebase (for example, logger.Debug(...) or similar).

@kindermax kindermax force-pushed the codex/adapt-cli-update-plan-to branch from 02c5d6b to 1877c75 Compare March 19, 2026 20:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant