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:
@@ -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,
|
||||
))
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user