feat: update changelog (#131)

closes #130 

- CHANGELOG.md file for people who can view changelog from github
---
`iris changelog` command:
`iris changelog [version]` (e.g. iris changelog v0.5.1)
`iris changelog -n 3` (see changelog of 3 latest releaseas)

=> `iris changelog` looks just like CHANGELOG.md, grouped headers and
all. It only stops looking that way if we personally overwrite that
release's description with something else
=> With 1-hour local cache (invalidated on updater-channel change),
`--refresh` flag to bypass, graceful fallback to stale cache on GitHub
rate-limiting

---
- New auto update config
```toml
# 0 = off (default, notify only), 1 = auto-install, 2 = always confirm first
auto-update = 1
```

- New just debug-changelog and just debug-autoupdate recipes (fully
offline, mocked HTTP servers, no real network/config touched)
This commit is contained in:
VERSE
2026-08-07 17:16:18 +07:00
committed by GitHub
parent 4a4ab3c102
commit 87b91c45d7
25 changed files with 2275 additions and 90 deletions
+173
View File
@@ -0,0 +1,173 @@
package root
import (
"context"
"fmt"
"os"
"os/exec"
"strings"
"sync/atomic"
"time"
"github.com/versenilvis/iris/internal/config"
)
const installScriptURL = "https://raw.githubusercontent.com/versenilvis/iris/main/scripts/install.sh"
func resolveInstallScriptURL() string {
if url := os.Getenv("IRIS_INSTALL_URL"); url != "" {
return url
}
return installScriptURL
}
// non-interactive output is captured, not streamed - the wrapper's terminal
// is in raw mode, so streaming installer output would corrupt the display
func performUpdate(latest string, interactive bool) (output string, err error) {
ctx := context.Background()
if !interactive {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, 5*time.Minute)
defer cancel()
}
cmdRun := exec.CommandContext(ctx, "sh", "-c", "curl -sSL "+resolveInstallScriptURL()+" | sh")
if config.Get().Updater.Channel == "nightly" {
cmdRun.Env = append(os.Environ(), "IRIS_RELEASE_TAG="+latest)
}
if interactive {
cmdRun.Stdout = os.Stdout
cmdRun.Stderr = os.Stderr
cmdRun.Stdin = os.Stdin
return "", cmdRun.Run()
}
out, runErr := cmdRun.CombinedOutput()
return string(out), runErr
}
type autoUpdateAction int
const (
autoUpdateNotifyOnly autoUpdateAction = iota
autoUpdateInstallSilently
autoUpdateConfirm
autoUpdateGiveUp
)
// mode 1 escalates per target version: attempt 1 installs silently, attempt
// 2 asks first, attempt 3+ gives up. mode 2 always asks, never gives up
func decideAutoUpdateAction(mode int, latest string, st config.UpdaterState) autoUpdateAction {
if mode == 0 {
return autoUpdateNotifyOnly
}
if latest == st.DeclinedVersion {
return autoUpdateNotifyOnly
}
if mode == 2 {
return autoUpdateConfirm
}
attempt := st.AutoUpdateAttempt
if st.AutoUpdateTarget != latest {
attempt = 0
}
switch attempt {
case 0:
return autoUpdateInstallSilently
case 1:
return autoUpdateConfirm
default:
return autoUpdateGiveUp
}
}
var (
pendingAutoUpdateConfirm atomic.Bool
pendingAutoUpdateVersion atomic.Value
)
func armAutoUpdateConfirm(version string) {
pendingAutoUpdateVersion.Store(version)
pendingAutoUpdateConfirm.Store(true)
}
// reports whether the byte was consumed - callers must not fall through
// to normal key handling when true
func handleAutoUpdateConfirmKey(b byte) bool {
if !pendingAutoUpdateConfirm.Load() {
return false
}
switch b {
case 'y', 'Y':
pendingAutoUpdateConfirm.Store(false)
version, _ := pendingAutoUpdateVersion.Load().(string)
go runConfirmedAutoUpdate(version)
case 'n', 'N', 0x1b, 0x03: // n, N, Esc, Ctrl+C
pendingAutoUpdateConfirm.Store(false)
version, _ := pendingAutoUpdateVersion.Load().(string)
declineAutoUpdate(version)
}
return true
}
func runConfirmedAutoUpdate(version string) {
if version == "" {
return
}
writeStdout([]byte("\r\033[K\033[36m[IRIS] updating...\033[0m\n"))
if _, err := performUpdate(version, false); err != nil {
writeStdout(fmt.Appendf(nil, "\033[31m[IRIS] update failed: %v\033[0m\n", err))
return
}
state := config.LoadState()
state.Updater.AutoUpdateTarget = ""
state.Updater.AutoUpdateAttempt = 0
state.Updater.SeenVersion = ""
_ = config.SaveState(state)
writeStdout(fmt.Appendf(nil,
"\033[32m[IRIS] updated to %s, restart your terminal to use it\033[0m\n", version,
))
}
func declineAutoUpdate(version string) {
if version == "" {
return
}
state := config.LoadState()
state.Updater.DeclinedVersion = version
state.Updater.AutoUpdateTarget = ""
state.Updater.AutoUpdateAttempt = 0
_ = config.SaveState(state)
writeStdout([]byte("\r\033[K\033[33m[IRIS] update declined, run `iris update` any time to install manually\033[0m\n"))
}
func printAutoUpdateInstalledNotice(latest string) {
writeStdout(fmt.Appendf(nil,
"\r\033[K\033[32m[IRIS] auto-updated %s → %s\033[0m\n\033[32m[IRIS] restart your terminal to use the new version\033[0m\n",
Version, latest,
))
}
func printAutoUpdateConfirmPrompt(latest, notes string) {
var b strings.Builder
fmt.Fprintf(&b, "\r\033[K\033[33m[IRIS] new version %s → %s available\033[0m\n", Version, latest)
for _, line := range changelogSummaryLines(notes, 2) {
fmt.Fprintf(&b, "\033[33m - %s\033[0m\n", line)
}
fmt.Fprint(&b, "\033[33minstall now? [y/N] \033[0m")
writeStdout([]byte(b.String()))
}
func printAutoUpdateGiveUpNotice(latest string) {
writeStdout(fmt.Appendf(nil,
"\r\033[K\033[33m[IRIS] could not auto-update to %s after repeated attempts, run \033[1miris update\033[0m\033[33m to install manually\033[0m\n",
latest,
))
}
+209
View File
@@ -0,0 +1,209 @@
package root
import (
"io"
"os"
"strings"
"testing"
"github.com/versenilvis/iris/internal/config"
)
// captureStdout redirects os.Stdout for the duration of fn and returns
// everything written to it. writeStdout writes to os.Stdout directly, so
// this is the only way to observe it without a real terminal.
func captureStdout(t *testing.T, fn func()) string {
t.Helper()
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("os.Pipe: %v", err)
}
original := os.Stdout
os.Stdout = w
fn()
_ = w.Close()
os.Stdout = original
out, _ := io.ReadAll(r)
return string(out)
}
func TestDecideAutoUpdateActionOff(t *testing.T) {
st := config.UpdaterState{AutoUpdateTarget: "v1.1.0", AutoUpdateAttempt: 5}
if got := decideAutoUpdateAction(0, "v1.1.0", st); got != autoUpdateNotifyOnly {
t.Errorf("mode 0: expected notifyOnly, got %v", got)
}
}
func TestDecideAutoUpdateActionDeclinedVersionNeverRePrompted(t *testing.T) {
st := config.UpdaterState{DeclinedVersion: "v1.1.0"}
if got := decideAutoUpdateAction(1, "v1.1.0", st); got != autoUpdateNotifyOnly {
t.Errorf("mode 1, declined: expected notifyOnly, got %v", got)
}
if got := decideAutoUpdateAction(2, "v1.1.0", st); got != autoUpdateNotifyOnly {
t.Errorf("mode 2, declined: expected notifyOnly, got %v", got)
}
}
func TestDecideAutoUpdateActionModeTwoAlwaysConfirms(t *testing.T) {
cases := []config.UpdaterState{
{},
{AutoUpdateTarget: "v1.1.0", AutoUpdateAttempt: 0},
{AutoUpdateTarget: "v1.1.0", AutoUpdateAttempt: 1},
{AutoUpdateTarget: "v1.1.0", AutoUpdateAttempt: 3},
}
for _, st := range cases {
if got := decideAutoUpdateAction(2, "v1.1.0", st); got != autoUpdateConfirm {
t.Errorf("mode 2, state %+v: expected confirm, got %v", st, got)
}
}
}
func TestDecideAutoUpdateActionModeOneEscalationLadder(t *testing.T) {
// a different or absent target: this is a fresh version, try #1
if got := decideAutoUpdateAction(1, "v1.1.0", config.UpdaterState{}); got != autoUpdateInstallSilently {
t.Errorf("fresh target: expected installSilently, got %v", got)
}
if got := decideAutoUpdateAction(1, "v1.1.0", config.UpdaterState{AutoUpdateTarget: "v1.0.5", AutoUpdateAttempt: 2}); got != autoUpdateInstallSilently {
t.Errorf("different target: expected installSilently (attempt resets), got %v", got)
}
// same target, one attempt already made: this is try #2
st := config.UpdaterState{AutoUpdateTarget: "v1.1.0", AutoUpdateAttempt: 1}
if got := decideAutoUpdateAction(1, "v1.1.0", st); got != autoUpdateConfirm {
t.Errorf("attempt 1 stored: expected confirm, got %v", got)
}
// same target, two attempts already made: this is try #3, give up
st = config.UpdaterState{AutoUpdateTarget: "v1.1.0", AutoUpdateAttempt: 2}
if got := decideAutoUpdateAction(1, "v1.1.0", st); got != autoUpdateGiveUp {
t.Errorf("attempt 2 stored: expected giveUp, got %v", got)
}
// already given up: stays given up, doesn't loop back to installing
st = config.UpdaterState{AutoUpdateTarget: "v1.1.0", AutoUpdateAttempt: 3}
if got := decideAutoUpdateAction(1, "v1.1.0", st); got != autoUpdateGiveUp {
t.Errorf("attempt 3 stored: expected giveUp (stays given up), got %v", got)
}
}
func TestDecideAutoUpdateActionNewerVersionResetsLadder(t *testing.T) {
// gave up on v1.1.0, but v1.2.0 is a different target entirely - fresh start
st := config.UpdaterState{AutoUpdateTarget: "v1.1.0", AutoUpdateAttempt: 3}
if got := decideAutoUpdateAction(1, "v1.2.0", st); got != autoUpdateInstallSilently {
t.Errorf("newer target after giving up on an older one: expected installSilently, got %v", got)
}
}
func TestPrintAutoUpdateInstalledNotice(t *testing.T) {
originalVersion := Version
Version = "v1.0.0"
t.Cleanup(func() { Version = originalVersion })
out := captureStdout(t, func() { printAutoUpdateInstalledNotice("v1.1.0") })
if !strings.Contains(out, "v1.0.0") || !strings.Contains(out, "v1.1.0") {
t.Errorf("expected the version transition, got %q", out)
}
if !strings.Contains(out, "restart your terminal") {
t.Errorf("expected a restart notice, got %q", out)
}
}
func TestPrintAutoUpdateConfirmPrompt(t *testing.T) {
originalVersion := Version
Version = "v1.0.0"
t.Cleanup(func() { Version = originalVersion })
out := captureStdout(t, func() {
printAutoUpdateConfirmPrompt("v1.1.0", "## Changelog\n### Bug fixes\n* abc1234 fix something\n")
})
if !strings.Contains(out, "v1.1.0") {
t.Errorf("expected the target version, got %q", out)
}
if !strings.Contains(out, "fix something") {
t.Errorf("expected the changelog summary, got %q", out)
}
if !strings.Contains(out, "install now?") {
t.Errorf("expected the confirm prompt text, got %q", out)
}
}
func TestPrintAutoUpdateGiveUpNotice(t *testing.T) {
out := captureStdout(t, func() { printAutoUpdateGiveUpNotice("v1.1.0") })
if !strings.Contains(out, "v1.1.0") {
t.Errorf("expected the target version, got %q", out)
}
if !strings.Contains(out, "iris update") {
t.Errorf("expected a pointer to manual iris update, got %q", out)
}
}
func TestDeclineAutoUpdateRecordsState(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("HOME", tmpDir)
t.Setenv("XDG_DATA_HOME", tmpDir+"/.local/share")
state := config.LoadState()
state.Updater.AutoUpdateTarget = "v1.1.0"
state.Updater.AutoUpdateAttempt = 2
if err := config.SaveState(state); err != nil {
t.Fatalf("SaveState: %v", err)
}
captureStdout(t, func() { declineAutoUpdate("v1.1.0") })
got := config.LoadState()
if got.Updater.DeclinedVersion != "v1.1.0" {
t.Errorf("expected DeclinedVersion v1.1.0, got %q", got.Updater.DeclinedVersion)
}
if got.Updater.AutoUpdateTarget != "" || got.Updater.AutoUpdateAttempt != 0 {
t.Errorf("expected the escalation ladder to be reset, got target=%q attempt=%d",
got.Updater.AutoUpdateTarget, got.Updater.AutoUpdateAttempt)
}
}
func TestHandleAutoUpdateConfirmKeyNotArmedIsNoop(t *testing.T) {
pendingAutoUpdateConfirm.Store(false)
if handleAutoUpdateConfirmKey('y') {
t.Error("expected handleAutoUpdateConfirmKey to report unhandled when nothing is pending")
}
}
func TestHandleAutoUpdateConfirmKeyDeclineFlow(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("HOME", tmpDir)
t.Setenv("XDG_DATA_HOME", tmpDir+"/.local/share")
t.Cleanup(func() { pendingAutoUpdateConfirm.Store(false) })
armAutoUpdateConfirm("v1.1.0")
captureStdout(t, func() {
if !handleAutoUpdateConfirmKey('n') {
t.Error("expected 'n' to be reported as handled while a confirm is pending")
}
})
if pendingAutoUpdateConfirm.Load() {
t.Error("expected the pending confirm flag to clear after declining")
}
if got := config.LoadState().Updater.DeclinedVersion; got != "v1.1.0" {
t.Errorf("expected the decline to be recorded, got %q", got)
}
}
func TestHandleAutoUpdateConfirmKeySwallowsUnrecognizedBytes(t *testing.T) {
t.Cleanup(func() { pendingAutoUpdateConfirm.Store(false) })
armAutoUpdateConfirm("v1.1.0")
if !handleAutoUpdateConfirmKey('x') {
t.Error("expected an unrecognized byte to still be reported as consumed while pending")
}
if !pendingAutoUpdateConfirm.Load() {
t.Error("expected the prompt to stay armed until y/n/Esc/Ctrl+C")
}
}
+312
View File
@@ -0,0 +1,312 @@
package root
import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/charmbracelet/glamour"
"github.com/charmbracelet/x/ansi"
"github.com/spf13/cobra"
"github.com/versenilvis/iris/internal/config"
"golang.org/x/term"
)
type Release struct {
TagName string `json:"tag_name"`
PublishedAt time.Time `json:"published_at"`
Body string `json:"body"`
Prerelease bool `json:"prerelease"`
}
type changelogCache struct {
FetchedAt time.Time `json:"fetched_at"`
Channel string `json:"channel"`
Releases []Release `json:"releases"`
}
const changelogCacheTTL = time.Hour
func changelogCachePath() (string, error) {
dir, err := config.CachePath()
if err != nil {
return "", err
}
return filepath.Join(dir, "changelog-cache.json"), nil
}
func loadChangelogCache() (*changelogCache, error) {
path, err := changelogCachePath()
if err != nil {
return nil, err
}
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var cache changelogCache
if err := json.Unmarshal(data, &cache); err != nil {
return nil, err
}
return &cache, nil
}
func saveChangelogCache(releases []Release) {
path, err := changelogCachePath()
if err != nil {
return
}
data, err := json.Marshal(changelogCache{FetchedAt: time.Now(), Channel: config.Get().Updater.Channel, Releases: releases})
if err != nil {
return
}
_ = os.MkdirAll(filepath.Dir(path), 0o755)
_ = os.WriteFile(path, data, 0o644)
}
func FetchReleases(limit int) ([]Release, error) {
ctx, cancel := newGitHubRequestContext()
defer cancel()
endpoint := os.Getenv("IRIS_CHANGELOG_URL")
if endpoint == "" {
endpoint = "https://api.github.com/repos/versenilvis/iris/releases?per_page=100"
}
body, err := fetchGitHubBody(ctx, endpoint)
if err != nil {
return nil, err
}
var releases []Release
if err := json.Unmarshal(body, &releases); err != nil {
return nil, err
}
if config.Get().Updater.Channel != "nightly" {
filtered := releases[:0]
for _, r := range releases {
if !r.Prerelease {
filtered = append(filtered, r)
}
}
releases = filtered
}
return truncateReleases(releases, limit), nil
}
func truncateReleases(releases []Release, limit int) []Release {
if limit > 0 && len(releases) > limit {
return releases[:limit]
}
return releases
}
// falls back to a stale cache rather than failing outright when rate limited
func FetchReleasesCached(limit int, refresh bool) (releases []Release, rateLimited bool, err error) {
channel := config.Get().Updater.Channel
if !refresh {
if cache, cacheErr := loadChangelogCache(); cacheErr == nil && cache.Channel == channel && time.Since(cache.FetchedAt) < changelogCacheTTL {
return truncateReleases(cache.Releases, limit), false, nil
}
}
fresh, fetchErr := FetchReleases(100)
if fetchErr != nil {
if errors.Is(fetchErr, ErrRateLimited) {
if cache, cacheErr := loadChangelogCache(); cacheErr == nil && cache.Channel == channel && len(cache.Releases) > 0 {
return truncateReleases(cache.Releases, limit), true, nil
}
}
return nil, false, fetchErr
}
saveChangelogCache(fresh)
return truncateReleases(fresh, limit), false, nil
}
func releaseURL(tag string) string {
return "https://github.com/versenilvis/iris/releases/tag/" + tag
}
var (
changelogRenderer *glamour.TermRenderer
changelogRendererOnce sync.Once
errChangelogRenderer error
)
func changelogRenderWidth() int {
if w, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil && w > 0 && w < 120 {
return w
}
return 120
}
func renderChangelogMarkdown(body string) (string, error) {
changelogRendererOnce.Do(func() {
changelogRenderer, errChangelogRenderer = glamour.NewTermRenderer(
glamour.WithStandardStyle("dark"),
glamour.WithWordWrap(changelogRenderWidth()),
)
})
if errChangelogRenderer != nil {
return "", errChangelogRenderer
}
return changelogRenderer.Render(body)
}
func stripRedundantHeading(body string) string {
lines := strings.Split(body, "\n")
filtered := lines[:0]
for _, line := range lines {
if strings.TrimSpace(line) == "## Changelog" {
continue
}
filtered = append(filtered, line)
}
return strings.Join(filtered, "\n")
}
// judged post-strip: glamour's margin padding has color codes but no glyphs
func isBlankLine(line string) bool {
return strings.TrimSpace(ansi.Strip(line)) == ""
}
func squeezeBlankLines(s string) string {
lines := strings.Split(s, "\n")
out := make([]string, 0, len(lines))
prevBlank := false
for _, line := range lines {
blank := isBlankLine(line)
if blank && prevBlank {
continue
}
out = append(out, line)
prevBlank = blank
}
return strings.Join(out, "\n")
}
func trimBlankLines(s string) string {
lines := strings.Split(s, "\n")
start := 0
for start < len(lines) && isBlankLine(lines[start]) {
start++
}
end := len(lines)
for end > start && isBlankLine(lines[end-1]) {
end--
}
return strings.Join(lines[start:end], "\n")
}
// the CTA is rendered separately by printRelease based on IsNewer, so the
// GoReleaser footer is cut before handing the body to glamour
func printChangelogBody(out io.Writer, body string) {
if idx := strings.Index(body, "## Update"); idx != -1 {
body = body[:idx]
}
body = strings.TrimSpace(stripRedundantHeading(body))
if body == "" {
return
}
rendered, err := renderChangelogMarkdown(body)
if err != nil {
fmt.Fprintln(out, body)
return
}
fmt.Fprintln(out, trimBlankLines(squeezeBlankLines(rendered)))
}
func printRelease(out io.Writer, release Release, showUpdateCTA bool) {
label := release.TagName
if strings.TrimPrefix(release.TagName, "v") == strings.TrimPrefix(Version, "v") {
label += " (current)"
}
date := release.PublishedAt.Format("2006-01-02")
fmt.Fprintf(out, "\033[1;36mIRIS %s - %s\033[0m\n", label, date)
fmt.Fprintf(out, "\033[2m%s\033[0m\n\n", releaseURL(release.TagName))
printChangelogBody(out, release.Body)
if showUpdateCTA && IsNewer(Version, release.TagName) {
fmt.Fprintln(out)
fmt.Fprintln(out, "run `iris update` to install")
}
}
var (
changelogCount int
changelogRefresh bool
)
var ChangelogCmd = &cobra.Command{
Use: "changelog [version]",
Short: "show what changed in recent iris releases",
Args: cobra.MaximumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
limit := changelogCount
if len(args) == 1 {
limit = 0 // searching a specific version, not just the most recent N
}
releases, rateLimited, err := FetchReleasesCached(limit, changelogRefresh)
if err != nil {
if errors.Is(err, ErrRateLimited) {
fmt.Fprintln(cmd.ErrOrStderr(), "\033[31m[IRIS] rate limited by GitHub API, try again later\033[0m")
return
}
fmt.Fprintf(cmd.ErrOrStderr(), "\033[31m[IRIS] could not fetch changelog: %v\033[0m\n", err)
return
}
if len(releases) == 0 {
cmd.Println("no releases found")
return
}
out := cmd.OutOrStdout()
if len(args) == 1 {
target := strings.TrimPrefix(args[0], "v")
for _, release := range releases {
if strings.TrimPrefix(release.TagName, "v") == target {
printRelease(out, release, true)
if rateLimited {
fmt.Fprintln(out)
fmt.Fprintln(out, "\033[33m[IRIS] rate limited by GitHub API, showing cached data\033[0m")
}
return
}
}
fmt.Fprintf(cmd.ErrOrStderr(), "\033[31m[IRIS] release %s not found\033[0m\n", args[0])
return
}
for i, release := range releases {
if i > 0 {
fmt.Fprintln(out)
}
printRelease(out, release, i == 0)
}
if rateLimited {
fmt.Fprintln(out)
fmt.Fprintln(out, "\033[33m[IRIS] rate limited by GitHub API, showing cached data\033[0m")
}
},
}
func init() {
ChangelogCmd.Flags().IntVarP(&changelogCount, "count", "n", 1, "number of releases to show")
ChangelogCmd.Flags().BoolVar(&changelogRefresh, "refresh", false, "bypass the cache and fetch fresh data")
rootCmd.AddCommand(ChangelogCmd)
}
+383
View File
@@ -0,0 +1,383 @@
package root
import (
"bytes"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/charmbracelet/x/ansi"
"github.com/versenilvis/iris/internal/config"
)
func TestFetchReleasesFiltersPrereleasesOnStableChannel(t *testing.T) {
originalConfig := config.Get()
t.Cleanup(func() { config.Init(originalConfig) })
cfg := config.DefaultConfig()
cfg.Updater.Channel = "stable"
config.Init(cfg)
releases := []Release{
{TagName: "v0.6.0-nightly.abc", Prerelease: true, Body: "nightly"},
{TagName: "v0.5.2", Prerelease: false, Body: "stable"},
{TagName: "v0.5.1", Prerelease: false, Body: "stable"},
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(releases)
}))
defer srv.Close()
t.Setenv("IRIS_CHANGELOG_URL", srv.URL)
got, err := FetchReleases(0)
if err != nil {
t.Fatalf("FetchReleases: %v", err)
}
if len(got) != 2 {
t.Fatalf("expected 2 stable releases, got %d: %+v", len(got), got)
}
for _, r := range got {
if r.Prerelease {
t.Errorf("prerelease %q leaked into stable channel results", r.TagName)
}
}
}
func TestFetchReleasesLimitsCount(t *testing.T) {
originalConfig := config.Get()
t.Cleanup(func() { config.Init(originalConfig) })
config.Init(config.DefaultConfig())
releases := []Release{
{TagName: "v0.5.2"}, {TagName: "v0.5.1"}, {TagName: "v0.5.0"},
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(releases)
}))
defer srv.Close()
t.Setenv("IRIS_CHANGELOG_URL", srv.URL)
got, err := FetchReleases(2)
if err != nil {
t.Fatalf("FetchReleases: %v", err)
}
if len(got) != 2 {
t.Fatalf("expected limit of 2, got %d", len(got))
}
}
func seedChangelogCache(t *testing.T, fetchedAt time.Time, releases []Release) {
t.Helper()
path, err := changelogCachePath()
if err != nil {
t.Fatalf("changelogCachePath: %v", err)
}
data, err := json.Marshal(changelogCache{FetchedAt: fetchedAt, Channel: config.Get().Updater.Channel, Releases: releases})
if err != nil {
t.Fatalf("marshal cache: %v", err)
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("mkdir cache dir: %v", err)
}
if err := os.WriteFile(path, data, 0o644); err != nil {
t.Fatalf("write cache: %v", err)
}
}
func TestFetchReleasesCachedServesWithinTTL(t *testing.T) {
t.Setenv("XDG_CACHE_HOME", t.TempDir())
seedChangelogCache(t, time.Now(), []Release{{TagName: "v1.0.0", Body: "cached"}})
// point at an address nothing listens on: a network call here is a test failure
t.Setenv("IRIS_CHANGELOG_URL", "http://127.0.0.1:1/unreachable")
got, rateLimited, err := FetchReleasesCached(1, false)
if err != nil {
t.Fatalf("FetchReleasesCached: %v", err)
}
if rateLimited {
t.Error("expected rateLimited=false for a fresh cache hit")
}
if len(got) != 1 || got[0].TagName != "v1.0.0" {
t.Fatalf("expected cached release, got %+v", got)
}
}
func TestFetchReleasesCachedChannelChangeBypassesStaleCache(t *testing.T) {
originalConfig := config.Get()
t.Cleanup(func() { config.Init(originalConfig) })
cfg := config.DefaultConfig()
cfg.Updater.Channel = "nightly"
config.Init(cfg)
t.Setenv("XDG_CACHE_HOME", t.TempDir())
seedChangelogCache(t, time.Now(), []Release{{TagName: "v1.0.0-nightly.abc", Body: "nightly cache", Prerelease: true}})
cfg.Updater.Channel = "stable"
config.Init(cfg)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode([]Release{{TagName: "v1.0.0", Body: "stable release"}})
}))
defer srv.Close()
t.Setenv("IRIS_CHANGELOG_URL", srv.URL)
got, _, err := FetchReleasesCached(1, false)
if err != nil {
t.Fatalf("FetchReleasesCached: %v", err)
}
if len(got) != 1 || got[0].TagName != "v1.0.0" {
t.Fatalf("expected the channel switch to bypass the nightly-channel cache and fetch fresh, got %+v", got)
}
}
func TestFetchReleasesCachedRefreshBypassesTTL(t *testing.T) {
originalConfig := config.Get()
t.Cleanup(func() { config.Init(originalConfig) })
config.Init(config.DefaultConfig())
t.Setenv("XDG_CACHE_HOME", t.TempDir())
seedChangelogCache(t, time.Now(), []Release{{TagName: "v1.0.0", Body: "stale-but-fresh"}})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode([]Release{{TagName: "v2.0.0", Body: "fresh"}})
}))
defer srv.Close()
t.Setenv("IRIS_CHANGELOG_URL", srv.URL)
got, _, err := FetchReleasesCached(1, true)
if err != nil {
t.Fatalf("FetchReleasesCached: %v", err)
}
if len(got) != 1 || got[0].TagName != "v2.0.0" {
t.Fatalf("expected --refresh to bypass the cache, got %+v", got)
}
}
func TestFetchReleasesCachedRateLimitFallsBackToStaleCache(t *testing.T) {
t.Setenv("XDG_CACHE_HOME", t.TempDir())
seedChangelogCache(t, time.Now().Add(-2*changelogCacheTTL), []Release{{TagName: "v1.0.0", Body: "stale"}})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusForbidden)
}))
defer srv.Close()
t.Setenv("IRIS_CHANGELOG_URL", srv.URL)
got, rateLimited, err := FetchReleasesCached(1, false)
if err != nil {
t.Fatalf("FetchReleasesCached: %v", err)
}
if !rateLimited {
t.Error("expected rateLimited=true when the API 403s and a stale cache exists")
}
if len(got) != 1 || got[0].TagName != "v1.0.0" {
t.Fatalf("expected stale cached release, got %+v", got)
}
}
func TestFetchReleasesCachedRateLimitNoCacheReturnsError(t *testing.T) {
t.Setenv("XDG_CACHE_HOME", t.TempDir())
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusForbidden)
}))
defer srv.Close()
t.Setenv("IRIS_CHANGELOG_URL", srv.URL)
_, _, err := FetchReleasesCached(1, false)
if !errors.Is(err, ErrRateLimited) {
t.Fatalf("expected ErrRateLimited, got %v", err)
}
}
func TestPrintReleaseSuppressesUpdateCTAWhenCurrent(t *testing.T) {
originalVersion := Version
Version = "v1.0.0"
t.Cleanup(func() { Version = originalVersion })
var buf bytes.Buffer
printRelease(&buf, Release{TagName: "v1.0.0", Body: "## Changelog\n### Bug fixes\n* abc1234 fix something\n"}, true)
if strings.Contains(buf.String(), "run `iris update`") {
t.Error("expected no update CTA when already on the latest version")
}
if !strings.Contains(buf.String(), "(current)") {
t.Error("expected the version label to be marked (current)")
}
}
func TestPrintReleaseShowsUpdateCTAWhenNewer(t *testing.T) {
originalVersion := Version
Version = "v0.9.0"
t.Cleanup(func() { Version = originalVersion })
var buf bytes.Buffer
printRelease(&buf, Release{TagName: "v1.0.0", Body: "## Changelog\n### Bug fixes\n* abc1234 fix something\n"}, true)
if !strings.Contains(buf.String(), "run `iris update`") {
t.Error("expected an update CTA when a newer release is shown")
}
}
func TestPrintChangelogBodyStopsAtUpdateFooter(t *testing.T) {
var buf bytes.Buffer
body := "## Changelog\n### Bug fixes\n* abc1234 fix something\n## Update\n```bash\niris update\n```\n"
printChangelogBody(&buf, body)
plain := ansi.Strip(buf.String())
if strings.Contains(plain, "Update") {
t.Error("expected the GoReleaser footer to be excluded from rendered body")
}
if !strings.Contains(plain, "fix something") {
t.Errorf("expected the changelog entry to be rendered, got %q", plain)
}
}
func TestPrintChangelogBodyRendersArbitraryMarkdown(t *testing.T) {
var buf bytes.Buffer
body := "# 🌙 **Nightly build** - `v0.6.0-nightly.abc123`\nNightly release from commit: abc123\n"
printChangelogBody(&buf, body)
plain := ansi.Strip(buf.String())
if !strings.Contains(plain, "Nightly build") {
t.Errorf("expected the body rendered via glamour, got %q", plain)
}
}
func TestPrintChangelogBodyEmptyBodyPrintsNothing(t *testing.T) {
var buf bytes.Buffer
printChangelogBody(&buf, "")
if buf.Len() != 0 {
t.Errorf("expected no output for an empty body, got %q", buf.String())
}
}
func TestPrintChangelogBodyStripsRedundantHeadingAndBlankRuns(t *testing.T) {
var buf bytes.Buffer
body := "## Changelog\n### Bug fixes\n* abc1234 fix something\n"
printChangelogBody(&buf, body)
plain := ansi.Strip(buf.String())
if strings.Contains(plain, "Changelog") {
t.Errorf("expected the redundant ## Changelog heading to be stripped, got %q", plain)
}
if strings.Contains(buf.String(), "\n\n\n") {
t.Errorf("expected consecutive blank lines to be squeezed, got %q", buf.String())
}
}
func TestStripRedundantHeadingRemovesExactGoReleaserLabelOnly(t *testing.T) {
got := stripRedundantHeading("## Changelog\n### Bug fixes\n* item\n")
want := "### Bug fixes\n* item\n"
if got != want {
t.Errorf("got %q, want %q", got, want)
}
}
func TestStripRedundantHeadingPreservesHandWrittenHeadings(t *testing.T) {
body := "## Theme is here\n<img src=\"...\" />\n"
got := stripRedundantHeading(body)
if got != body {
t.Errorf("expected a hand-written ## heading to be preserved, got %q", got)
}
}
func TestPrintChangelogBodyPreservesHandWrittenAnnouncement(t *testing.T) {
var buf bytes.Buffer
body := "## Theme is here\r\n<img width=\"1818\" src=\"...\" />\r\n\r\n## Update\r\n```bash\r\niris update\r\n```\r\n"
printChangelogBody(&buf, body)
plain := ansi.Strip(buf.String())
if !strings.Contains(plain, "Theme is here") {
t.Errorf("expected the hand-written release heading to be rendered, got %q", plain)
}
}
func TestSqueezeBlankLinesCollapsesRuns(t *testing.T) {
got := squeezeBlankLines("a\n\n\n\nb\n")
want := "a\n\nb\n"
if got != want {
t.Errorf("got %q, want %q", got, want)
}
}
func TestChangelogCmdShowsSpecificVersion(t *testing.T) {
originalConfig := config.Get()
t.Cleanup(func() { config.Init(originalConfig) })
config.Init(config.DefaultConfig())
t.Setenv("XDG_CACHE_HOME", t.TempDir())
releases := []Release{
{TagName: "v0.5.2", Body: "## Changelog\n### Bug fixes\n* xyz9999 unrelated fix\n"},
{TagName: "v0.5.1", Body: "## Changelog\n### Bug fixes\n* abc1234 fix mode\n"},
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(releases)
}))
defer srv.Close()
t.Setenv("IRIS_CHANGELOG_URL", srv.URL)
var out, errOut bytes.Buffer
ChangelogCmd.SetOut(&out)
ChangelogCmd.SetErr(&errOut)
t.Cleanup(func() {
ChangelogCmd.SetOut(nil)
ChangelogCmd.SetErr(nil)
})
ChangelogCmd.Run(ChangelogCmd, []string{"v0.5.1"})
got := ansi.Strip(out.String())
if !strings.Contains(got, "v0.5.1") {
t.Fatalf("expected the requested version in output, got %q", got)
}
if strings.Contains(got, "v0.5.2") || strings.Contains(got, "unrelated fix") {
t.Errorf("expected only the requested version, got %q", got)
}
if !strings.Contains(got, "fix mode") {
t.Errorf("expected the matched release's body, got %q", got)
}
}
func TestChangelogCmdVersionNotFound(t *testing.T) {
originalConfig := config.Get()
t.Cleanup(func() { config.Init(originalConfig) })
config.Init(config.DefaultConfig())
t.Setenv("XDG_CACHE_HOME", t.TempDir())
releases := []Release{{TagName: "v0.5.2", Body: "latest"}}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(releases)
}))
defer srv.Close()
t.Setenv("IRIS_CHANGELOG_URL", srv.URL)
var out, errOut bytes.Buffer
ChangelogCmd.SetOut(&out)
ChangelogCmd.SetErr(&errOut)
t.Cleanup(func() {
ChangelogCmd.SetOut(nil)
ChangelogCmd.SetErr(nil)
})
ChangelogCmd.Run(ChangelogCmd, []string{"v9.9.9"})
if !strings.Contains(errOut.String(), "not found") {
t.Errorf("expected a not-found error, got stdout=%q stderr=%q", out.String(), errOut.String())
}
}
+3
View File
@@ -98,6 +98,9 @@ channel = "stable"
# interval between update checks, e.g. "24h", "6h", "30m"
check-interval = "24h"
# 0 = off (default, notify only), 1 = auto-install, 2 = always confirm first
auto-update = 0
[keybindings]
toggle-mode = "ctrl+r"
toggle-menu = "shift+tab"
+3
View File
@@ -282,6 +282,9 @@ channel = "stable"
# interval between update checks, e.g. "24h", "6h", "30m"
check-interval = "24h"
# 0 = off (default, notify only), 1 = auto-install, 2 = always confirm first
auto-update = 0
[keybindings]
toggle-mode = "ctrl+r"
toggle-menu = "shift+tab"
+151 -60
View File
@@ -3,11 +3,11 @@ package root
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"strconv"
"strings"
"time"
@@ -16,18 +16,59 @@ import (
"github.com/versenilvis/iris/internal/config"
)
type updateResultKind int
const (
updateResultNotify updateResultKind = iota
updateResultAutoInstalled
updateResultConfirm
updateResultGiveUp
)
// updateResult is passed from the async checker to the main loop
type updateResult struct {
kind updateResultKind
latestVersion string
notes string
hasUpdate bool
}
// pendingUpdate is set by the background goroutine and consumed once after the first IRIS_CMD_STOP
var pendingUpdate chan updateResult
// FetchLatestVersion hits the GitHub Releases API and returns the latest tag name
func FetchLatestVersion() (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
// ErrRateLimited is returned when the GitHub API responds 403/429.
var ErrRateLimited = errors.New("rate limited by GitHub API")
func newGitHubRequestContext() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), 5*time.Second)
}
func fetchGitHubBody(ctx context.Context, endpoint string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/vnd.github+json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusTooManyRequests {
return nil, ErrRateLimited
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
return io.ReadAll(resp.Body)
}
// FetchLatestRelease hits the GitHub Releases API and returns the latest release
func FetchLatestRelease() (Release, error) {
ctx, cancel := newGitHubRequestContext()
defer cancel()
endpoint := os.Getenv("IRIS_UPDATE_URL")
@@ -39,50 +80,39 @@ func FetchLatestVersion() (string, error) {
}
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
body, err := fetchGitHubBody(ctx, endpoint)
if err != nil {
return "", err
}
req.Header.Set("Accept", "application/vnd.github+json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
return Release{}, err
}
if config.Get().Updater.Channel == "nightly" && os.Getenv("IRIS_UPDATE_URL") == "" {
var releases []struct {
TagName string `json:"tag_name"`
}
var releases []Release
if err := json.Unmarshal(body, &releases); err != nil {
return "", err
return Release{}, err
}
if len(releases) == 0 {
return "", fmt.Errorf("no releases found")
return Release{}, fmt.Errorf("no releases found")
}
return releases[0].TagName, nil
return releases[0], nil
}
var result struct {
TagName string `json:"tag_name"`
var release Release
if err := json.Unmarshal(body, &release); err != nil {
return Release{}, err
}
if err := json.Unmarshal(body, &result); err != nil {
if release.TagName == "" {
return Release{}, fmt.Errorf("no tag_name in response")
}
return release, nil
}
// FetchLatestVersion hits the GitHub Releases API and returns the latest tag name
func FetchLatestVersion() (string, error) {
release, err := FetchLatestRelease()
if err != nil {
return "", err
}
if result.TagName == "" {
return "", fmt.Errorf("no tag_name in response")
}
return result.TagName, nil
return release.TagName, nil
}
// IsNewer returns true if latest is a newer semantic version than current.
@@ -175,40 +205,111 @@ func startBackgroundUpdateCheck() chan updateResult {
return
}
latest, err := FetchLatestVersion()
release, err := FetchLatestRelease()
if err != nil {
// no network or API error: silently do nothing
return
}
latest := release.TagName
// update the last check time regardless of result
state.Updater.LastCheckTime = time.Now()
if IsNewer(Version, latest) {
// only notify if user hasn't already seen this specific version notification
if state.Updater.SeenVersion != latest {
ch <- updateResult{latestVersion: latest, hasUpdate: true}
mode := config.Get().Updater.AutoUpdate
switch decideAutoUpdateAction(mode, latest, state.Updater) {
case autoUpdateNotifyOnly:
// only notify if user hasn't already seen this specific version notification
if state.Updater.SeenVersion != latest {
ch <- updateResult{kind: updateResultNotify, latestVersion: latest, notes: release.Body, hasUpdate: true}
}
// save the latest as seen_version so future sessions don't re-notify
// unless a NEWER version comes out (different tag)
state.Updater.SeenVersion = latest
_ = config.SaveState(state)
return
case autoUpdateInstallSilently:
state.Updater.AutoUpdateTarget = latest
state.Updater.AutoUpdateAttempt = 1
// write before installing so a crash still counts as an attempt
_ = config.SaveState(state)
if _, installErr := performUpdate(latest, false); installErr == nil {
state.Updater.AutoUpdateTarget = ""
state.Updater.AutoUpdateAttempt = 0
state.Updater.SeenVersion = ""
_ = config.SaveState(state)
ch <- updateResult{kind: updateResultAutoInstalled, latestVersion: latest, notes: release.Body, hasUpdate: true}
}
// on failure: state already recorded attempt 1 for this
// target, so the next check escalates to a confirm prompt
return
case autoUpdateConfirm:
nextAttempt := 1
if state.Updater.AutoUpdateTarget == latest {
nextAttempt = state.Updater.AutoUpdateAttempt + 1
}
state.Updater.AutoUpdateTarget = latest
state.Updater.AutoUpdateAttempt = nextAttempt
_ = config.SaveState(state)
ch <- updateResult{kind: updateResultConfirm, latestVersion: latest, notes: release.Body, hasUpdate: true}
return
case autoUpdateGiveUp:
// only announce the exact transition into giving up, not
// every cycle after
announce := state.Updater.AutoUpdateAttempt == 2
state.Updater.AutoUpdateAttempt = 3
_ = config.SaveState(state)
if announce {
ch <- updateResult{kind: updateResultGiveUp, latestVersion: latest, hasUpdate: true}
}
return
}
// save the latest as seen_version so future sessions don't re-notify
// unless a NEWER version comes out (different tag)
state.Updater.SeenVersion = latest
} else {
// up to date: clear the seen_version flag so the next update triggers a fresh notification
state.Updater.SeenVersion = ""
}
// up to date, or nothing left to do: clear update-related state so
// the next detected version starts a fresh notify/attempt cycle
state.Updater.SeenVersion = ""
state.Updater.AutoUpdateTarget = ""
state.Updater.AutoUpdateAttempt = 0
_ = config.SaveState(state)
}()
return ch
}
// printUpdateNotice writes the one-time update message to stdout
func printUpdateNotice(latest string) {
fmt.Printf(
func changelogSummaryLines(body string, max int) []string {
var lines []string
for line := range strings.SplitSeq(body, "\n") {
line = strings.TrimRight(line, "\r")
if !strings.HasPrefix(line, "* ") {
continue
}
entry := strings.TrimPrefix(line, "* ")
_, msg, ok := strings.Cut(entry, " ")
if !ok {
msg = entry
}
lines = append(lines, msg)
if len(lines) >= max {
break
}
}
return lines
}
func printUpdateNotice(latest, notes string) {
var b strings.Builder
fmt.Fprintf(&b,
"\r\033[K\033[33m[IRIS] new version %s → %s available, run \033[1miris update\033[0m\033[33m to upgrade\033[0m\n",
Version, latest,
)
for _, line := range changelogSummaryLines(notes, 2) {
fmt.Fprintf(&b, "\033[33m - %s\033[0m\n", line)
}
writeStdout([]byte(b.String()))
}
func init() {
@@ -247,23 +348,13 @@ var updateCmd = &cobra.Command{
fmt.Printf("\033[36m[IRIS] updating %s → %s\033[0m\n", Version, latest)
// download and replace the binary using the install script
installScript := "https://raw.githubusercontent.com/versenilvis/iris/main/scripts/install.sh"
command := "curl -sSL " + installScript + " | sh"
runningPrefix := ""
if config.Get().Updater.Channel == "nightly" {
runningPrefix = fmt.Sprintf("IRIS_RELEASE_TAG=%s ", latest)
}
fmt.Printf("running: %s%s\n\n", runningPrefix, command)
fmt.Printf("running: %scurl -sSL %s | sh\n\n", runningPrefix, resolveInstallScriptURL())
cmdRun := exec.Command("sh", "-c", command)
if config.Get().Updater.Channel == "nightly" {
cmdRun.Env = append(os.Environ(), "IRIS_RELEASE_TAG="+latest)
}
cmdRun.Stdout = os.Stdout
cmdRun.Stderr = os.Stderr
cmdRun.Stdin = os.Stdin
if err := cmdRun.Run(); err != nil {
if _, err := performUpdate(latest, true); err != nil {
fmt.Printf("\n\033[31m[IRIS] update failed: %v\033[0m\n", err)
return
}
+77
View File
@@ -1,8 +1,10 @@
package root
import (
"io"
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -76,3 +78,78 @@ func TestUpdateState(t *testing.T) {
t.Errorf("Expected LastCheck %v, got %v", state.Updater.LastCheckTime, loaded.Updater.LastCheckTime)
}
}
func TestChangelogSummaryLines(t *testing.T) {
body := "## Changelog\n### Bug fixes\n* abc1234 fix something\n* def5678 fix another thing\n* ghi9012 a third fix\n## Update\n```bash\niris update\n```\n"
got := changelogSummaryLines(body, 2)
want := []string{"fix something", "fix another thing"}
if len(got) != len(want) {
t.Fatalf("expected %d lines, got %d: %v", len(want), len(got), got)
}
for i, line := range want {
if got[i] != line {
t.Errorf("line %d: expected %q, got %q", i, line, got[i])
}
}
}
func TestChangelogSummaryLinesEmptyBody(t *testing.T) {
if got := changelogSummaryLines("", 2); len(got) != 0 {
t.Errorf("expected no lines for an empty body, got %v", got)
}
}
func TestPrintUpdateNoticeIncludesChangelogSummary(t *testing.T) {
originalVersion := Version
Version = "v1.0.0"
t.Cleanup(func() { Version = originalVersion })
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("os.Pipe: %v", err)
}
originalStdout := os.Stdout
os.Stdout = w
printUpdateNotice("v1.1.0", "## Changelog\n### Bug fixes\n* abc1234 fix something\n* def5678 fix another thing\n* ghi9012 a third fix\n")
_ = w.Close()
os.Stdout = originalStdout
out, _ := io.ReadAll(r)
got := string(out)
if !strings.Contains(got, "v1.0.0") || !strings.Contains(got, "v1.1.0") {
t.Errorf("expected the version transition in the notice, got %q", got)
}
if !strings.Contains(got, "fix something") || !strings.Contains(got, "fix another thing") {
t.Errorf("expected the first two changelog entries in the notice, got %q", got)
}
if strings.Contains(got, "a third fix") {
t.Errorf("expected the summary to cap at 2 entries, got %q", got)
}
}
func TestPrintUpdateNoticeWithoutNotes(t *testing.T) {
originalVersion := Version
Version = "v1.0.0"
t.Cleanup(func() { Version = originalVersion })
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("os.Pipe: %v", err)
}
originalStdout := os.Stdout
os.Stdout = w
printUpdateNotice("v1.1.0", "")
_ = w.Close()
os.Stdout = originalStdout
out, _ := io.ReadAll(r)
got := string(out)
if !strings.Contains(got, "v1.1.0") {
t.Errorf("expected the notice to still show without changelog notes, got %q", got)
}
}
+17 -1
View File
@@ -555,7 +555,17 @@ func runWrapper() {
select {
case result, ok := <-pendingUpdate:
if ok && result.hasUpdate {
printUpdateNotice(result.latestVersion)
switch result.kind {
case updateResultAutoInstalled:
printAutoUpdateInstalledNotice(result.latestVersion)
case updateResultConfirm:
printAutoUpdateConfirmPrompt(result.latestVersion, result.notes)
armAutoUpdateConfirm(result.latestVersion)
case updateResultGiveUp:
printAutoUpdateGiveUpNotice(result.latestVersion)
default:
printUpdateNotice(result.latestVersion, result.notes)
}
updatePrinted = true
}
default:
@@ -778,6 +788,12 @@ func runWrapper() {
b := inputSlice[i]
intercepted = false
// while an auto-update confirm prompt is pending, every
// byte goes to it instead of normal key handling
if handleAutoUpdateConfirmKey(b) {
continue
}
if matched, consumed := config.MatchKey(inputSlice[i:], config.Get().Keybindings.ToggleMenu); matched {
intercepted = true
suggestionsEnabled = !suggestionsEnabled