feat: the update changelog covers every version you missed #61
No files matched your search
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
+52
-14
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user