From 75bab2528677086219817ff9bc08a36755c96dcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karl-Einar=20Bergstr=C3=B8m?= <1+keb@noreply.localhost> Date: Tue, 13 Oct 2026 20:25:28 +0200 Subject: [PATCH] feat: the update changelog covers every version you missed (#61) Latest reads the last 10 releases in one request instead of only the newest, so U and lazysecrets update --check show the changes of every release since the running version, each under its version. Drafts and pre-releases are skipped. A changelog taller than the screen is cut with a pointer to the release pages. Reviewed-on: https://git.kebcloud.no/keb/lazysecrets/pulls/61 --- cmd/lazysecrets/update.go | 2 +- docs/config.md | 2 +- internal/ui/app/update.go | 9 ++++- internal/update/update.go | 66 ++++++++++++++++++++++++++-------- internal/update/update_test.go | 39 +++++++++++++++++--- 5 files changed, 97 insertions(+), 21 deletions(-) diff --git a/cmd/lazysecrets/update.go b/cmd/lazysecrets/update.go index 388c29f..82db68d 100644 --- a/cmd/lazysecrets/update.go +++ b/cmd/lazysecrets/update.go @@ -55,7 +55,7 @@ func updateCmd(configPath string, args []string) error { fmt.Printf("lazysecrets %s is the latest (%s)\n", current, r.Tag) return nil } - fmt.Printf("lazysecrets %s is out (you have %s):\n\n%s\n\n", r.Tag, current, indent(r.Notes())) + fmt.Printf("lazysecrets %s is out (you have %s):\n\n%s\n\n", r.Tag, current, indent(r.Notes(current))) if check { fmt.Println("lazysecrets update installs it.") return nil diff --git a/docs/config.md b/docs/config.md index 4125ab4..4bc23dd 100644 --- a/docs/config.md +++ b/docs/config.md @@ -151,7 +151,7 @@ published on GitHub and on git.kebcloud.no, and it asks them in that order, the next when one doesn't answer within 5 seconds; `updates.updateSources` lists others, such as a mirror of your own. Offline, or with every server down, it says nothing. A newer release shows in the footer, `v0.1.1 ↑ v0.2.0`; `U` (or -`lazysecrets update --check`) shows its changelog. +`lazysecrets update --check`) shows what changed since your version, across the last 10 releases. Updating (`u` there, or `lazysecrets update`) downloads the release for your OS and CPU from the first server that has it, checks it against diff --git a/internal/ui/app/update.go b/internal/ui/app/update.go index 006b2ff..03eee22 100644 --- a/internal/ui/app/update.go +++ b/internal/ui/app/update.go @@ -110,15 +110,22 @@ func (m *Model) update() tea.Cmd { func (m *Model) showUpdate() { r := *m.newer lines := []string{m.st.Muted.Render("You have " + m.versionLabel() + ".")} - if notes := r.Notes(); notes != "" { + if notes := r.Notes(m.version); notes != "" { lines = append(lines, "") for _, l := range strings.Split(notes, "\n") { if h, ok := strings.CutPrefix(l, "### "); ok { l = m.st.Label.Render(h) + } else if h, ok := strings.CutPrefix(l, "## "); ok { // a release, when several are new + l = m.st.TitleFocused.Render(h) } lines = append(lines, l) } } + // A popup doesn't scroll: several releases behind, the rest is on + // the release pages. + if most := max(6, m.h-12); len(lines) > most { + lines = append(lines[:most-1], m.st.Muted.Render("… and more on the release pages")) + } opts := []option{{key: "u", label: "Update now", tip: "Download, check and replace this lazysecrets; restart to use it.", run: func(m *Model) tea.Cmd { return m.install(r) }}} if r.URL != "" { diff --git a/internal/update/update.go b/internal/update/update.go index c7e08f8..c78de13 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -28,6 +28,7 @@ import ( "path/filepath" "regexp" "runtime" + "slices" "strings" "time" @@ -53,18 +54,44 @@ const maxDownload = 200 << 20 // Release is a published release. type Release struct { - Tag string `json:"tag_name"` - Body string `json:"body"` // the changelog section, then install notes - URL string `json:"html_url"` + Tag string `json:"tag_name"` + // Body is the changelog section, then install notes. From Latest, + // it's the changelogs of the recent releases, each under a "## v…" + // heading, so someone a few versions behind sees all they missed. + Body string `json:"body"` + URL string `json:"html_url"` + Draft bool `json:"draft"` + Prerelease bool `json:"prerelease"` } -// Notes is the release's changelog: its body without the install notes -// every release ends with. -func (r Release) Notes() string { - notes, _, _ := strings.Cut(r.Body, "## Install") +// Notes is the changelog since the version current: of each release +// newer than it (just this one for a dev build), without the install +// notes every release ends with. +func (r Release) Notes(current string) string { + body, _, _ := strings.Cut(r.Body, "## Install") + var keep, headings []string + in := true // before any heading: a single release's notes + for _, l := range strings.Split(body, "\n") { + if tag, ok := strings.CutPrefix(l, "## "); ok && semver.IsValid(tag) { + in = Newer(current, tag) || tag == r.Tag + if in { + headings = append(headings, l) + } + } + if in { + keep = append(keep, l) + } + } + notes := strings.Join(keep, "\n") + if len(headings) == 1 { // the title names it + notes = strings.Replace(notes, headings[0], "", 1) + } return PlainLinks(strings.TrimSpace(notes)) } +// recentReleases is how many releases Latest reads the changelogs of. +const recentReleases = 10 + // PlainLinks shows Markdown links as their text, for a terminal. func PlainLinks(s string) string { return mdLink.ReplaceAllString(s, "$1") } @@ -128,18 +155,29 @@ func (c *Client) latest(ctx context.Context, repo string) (Release, error) { } ctx, cancel := context.WithTimeout(ctx, latestTimeout) defer cancel() - b, err := c.get(ctx, api+"/releases/latest", 1<<20) + // The recent releases in one request (per_page on GitHub, limit on + // Gitea), for their changelogs. + b, err := c.get(ctx, fmt.Sprintf("%s/releases?per_page=%d&limit=%[2]d", api, recentReleases), 1<<20) if err != nil { return Release{}, err } - var r Release - if err := json.Unmarshal(b, &r); err != nil { - return Release{}, fmt.Errorf("reading the latest release from %s: %w", repo, err) + var all []Release + if err := json.Unmarshal(b, &all); err != nil { + return Release{}, fmt.Errorf("reading the releases from %s: %w", repo, err) } - if !semver.IsValid(r.Tag) { - return Release{}, fmt.Errorf("latest release %q on %s isn't a version", r.Tag, repo) + all = slices.DeleteFunc(all, func(r Release) bool { return r.Draft || r.Prerelease || !semver.IsValid(r.Tag) }) + if len(all) == 0 { + return Release{}, fmt.Errorf("no releases on %s", repo) } - return r, nil + slices.SortFunc(all, func(a, b Release) int { return semver.Compare(b.Tag, a.Tag) }) + var notes strings.Builder + for _, r := range all { + section, _, _ := strings.Cut(r.Body, "## Install") + fmt.Fprintf(¬es, "## %s\n\n%s\n\n", r.Tag, strings.TrimSpace(section)) + } + latest := all[0] + latest.Body = strings.TrimSpace(notes.String()) + return latest, nil } // Newer reports whether latest is newer than current. A build that isn't diff --git a/internal/update/update_test.go b/internal/update/update_test.go index 190f7f0..6456347 100644 --- a/internal/update/update_test.go +++ b/internal/update/update_test.go @@ -39,11 +39,42 @@ func TestNewer(t *testing.T) { func TestNotes(t *testing.T) { r := Release{Body: "### Added\n\n- Install with a one-liner (#7)\n\n## Install\n\nOn macOS…"} - if got := r.Notes(); got != "### Added\n\n- Install with a one-liner (#7)" { + if got := r.Notes("0.1.1"); got != "### Added\n\n- Install with a one-liner (#7)" { t.Errorf("notes %q", got) } } +// Latest reads the recent releases, so the notes cover every version +// since the one running. +func TestNotesSince(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/repos/keb/lazysecrets/releases" { + http.NotFound(w, r) + return + } + fmt.Fprint(w, `[ + {"tag_name":"v0.4.0-rc1","body":"### Added\n\n- rc","prerelease":true}, + {"tag_name":"v0.2.0","body":"### Added\n\n- two\n\n## Install\n\ncurl"}, + {"tag_name":"v0.3.0","body":"### Fixed\n\n- three\n\n## Install\n\ncurl","html_url":"https://example.com/v0.3.0"}, + {"tag_name":"v0.1.1","body":"### Added\n\n- one"}, + {"tag_name":"v9.0.0","body":"draft","draft":true}]`) + })) + t.Cleanup(srv.Close) + r, err := (&Client{Servers: []string{srv.URL + "/keb/lazysecrets"}}).Latest(context.Background()) + if err != nil || r.Tag != "v0.3.0" || r.URL != "https://example.com/v0.3.0" { + t.Fatalf("latest %+v %v", r, err) + } + for current, want := range map[string]string{ + "0.1.1": "## v0.3.0\n\n### Fixed\n\n- three\n\n## v0.2.0\n\n### Added\n\n- two", + "0.2.0": "### Fixed\n\n- three", + "dev-2ba762c": "### Fixed\n\n- three", + } { + if got := r.Notes(current); got != want { + t.Errorf("notes since %s: %q, want %q", current, got, want) + } + } +} + func TestManaged(t *testing.T) { if Managed("/opt/homebrew/Cellar/lazysecrets/0.2.0/bin/lazysecrets") == "" || Managed("/nix/store/abc-lazysecrets/bin/lazysecrets") == "" { t.Error("package managers not recognised") @@ -70,8 +101,8 @@ func server(t *testing.T, tamper bool) *httptest.Server { sum[0] ^= 0xff } mux := http.NewServeMux() - mux.HandleFunc("/api/v1/repos/keb/lazysecrets/releases/latest", func(w http.ResponseWriter, _ *http.Request) { - fmt.Fprint(w, `{"tag_name":"v0.2.0","body":"### Added\n\n- x\n\n## Install\n","html_url":"https://example.com/r"}`) + mux.HandleFunc("/api/v1/repos/keb/lazysecrets/releases", func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprint(w, `[{"tag_name":"v0.2.0","body":"### Added\n\n- x\n\n## Install\n","html_url":"https://example.com/r"}]`) }) mux.HandleFunc("/keb/lazysecrets/releases/download/v0.2.0/checksums.txt", func(w http.ResponseWriter, _ *http.Request) { fmt.Fprintf(w, "%s %s.tar.gz\n", hex.EncodeToString(sum[:]), name) @@ -173,7 +204,7 @@ func TestInstallRefusesATamperedArchive(t *testing.T) { func TestNotesShowLinksAsText(t *testing.T) { r := Release{Body: "### Added\n\n- Search ([#11](https://git.example.com/pulls/11)) ([65dc2d7](https://git.example.com/commit/65dc2d7abc))\n\n## Install\n\ncurl …"} - if got, want := r.Notes(), "### Added\n\n- Search (#11) (65dc2d7)"; got != want { + if got, want := r.Notes("0.1.1"), "### Added\n\n- Search (#11) (65dc2d7)"; got != want { t.Errorf("notes %q, want %q", got, want) } }