1package agent
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "net/http"
8 "os"
9 "strings"
10 "time"
11)
12
13// ChangelogInfo is what FetchChangelog returns. Body is the markdown
14// from the GitHub release page; URL points back to that page so the
15// dialog can offer "open in browser".
16type ChangelogInfo struct {
17 Version string
18 Body string
19 URL string
20}
21
22// FetchChangelog hits the GitHub releases API for the given version
23// (must already include the leading "v") and returns the release
24// notes body. Returns an empty ChangelogInfo on any failure or when
25// the body is empty; the caller treats either as "skip silently".
26//
27// Honours $GITHUB_TOKEN for private-repo access. Times out at 4s so
28// startup never blocks on a flaky network.
29// semverOnly strips commit hash and date suffixes from version strings
30// like "0.1.12 (25b2bd4, 2026-04-25T09:25:45Z)" to get just "0.1.12".
31func semverOnly(v string) string {
32 if i := strings.IndexByte(v, ' '); i > 0 {
33 return v[:i]
34 }
35 return v
36}
37
38func FetchChangelog(ctx context.Context, version string) (ChangelogInfo, error) {
39 if isDevVersion(version) {
40 return ChangelogInfo{}, nil
41 }
42 version = semverOnly(version)
43
44 tag := version
45 if !strings.HasPrefix(tag, "v") {
46 tag = "v" + tag
47 }
48 url := fmt.Sprintf("https://api.github.com/repos/bnema/zut/releases/tags/%s", tag)
49 req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
50 if err != nil {
51 return ChangelogInfo{}, err
52 }
53 req.Header.Set("accept", "application/vnd.github+json")
54 req.Header.Set("x-github-api-version", "2022-11-28")
55 if tok := os.Getenv("GITHUB_TOKEN"); tok != "" {
56 req.Header.Set("authorization", "Bearer "+tok)
57 }
58
59 client := &http.Client{Timeout: 4 * time.Second}
60 resp, err := client.Do(req)
61 if err != nil {
62 return ChangelogInfo{}, err
63 }
64 defer resp.Body.Close()
65 if resp.StatusCode != 200 {
66 return ChangelogInfo{}, fmt.Errorf("github api %d", resp.StatusCode)
67 }
68
69 var body struct {
70 TagName string `json:"tag_name"`
71 HTMLURL string `json:"html_url"`
72 Body string `json:"body"`
73 }
74 if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
75 return ChangelogInfo{}, err
76 }
77 body.Body = strings.TrimSpace(body.Body)
78 if body.Body == "" {
79 return ChangelogInfo{}, nil
80 }
81 // Extract only the changelog section and strip markdown headers.
82 body.Body = extractChangelog(body.Body)
83 if body.Body == "" {
84 return ChangelogInfo{}, nil
85 }
86 return ChangelogInfo{
87 Version: strings.TrimPrefix(body.TagName, "v"),
88 Body: body.Body,
89 URL: body.HTMLURL,
90 }, nil
91}
92
93// extractChangelog pulls the content starting from "## Changelog"
94// (or the whole body if no such header exists) and strips markdown
95// heading markers (# ## ###) from every line so the TUI renders
96// clean text.
97func extractChangelog(body string) string {
98 lines := strings.Split(body, "\n")
99
100 // Find the "## Changelog" line.
101 start := -1
102 for i, l := range lines {
103 trimmed := strings.TrimSpace(l)
104 if strings.EqualFold(trimmed, "## changelog") ||
105 strings.EqualFold(trimmed, "## Changelog") {
106 start = i + 1
107 break
108 }
109 }
110 if start < 0 {
111 // No changelog header found; use the whole body.
112 start = 0
113 }
114
115 // Process remaining lines: strip # markers but mark headings
116 // so the renderer can color them.
117 var out []string
118 for _, l := range lines[start:] {
119 trimmed := strings.TrimSpace(l)
120 if trimmed == "" {
121 out = append(out, "")
122 continue
123 }
124 // Detect markdown headings and strip the # but keep text.
125 if strings.HasPrefix(trimmed, "#") {
126 heading := strings.TrimLeft(trimmed, "#")
127 heading = strings.TrimSpace(heading)
128 if heading != "" {
129 // Mark headings with a sentinel the dialog can detect.
130 out = append(out, "\x00H:"+heading)
131 }
132 continue
133 }
134 out = append(out, l)
135 }
136 return strings.TrimSpace(strings.Join(out, "\n"))
137}
138
139// FetchChangelogAsync runs FetchChangelog on a goroutine and delivers
140// the result on the returned channel. Channel always closes.
141func FetchChangelogAsync(version string) <-chan ChangelogInfo {
142 ch := make(chan ChangelogInfo, 1)
143 go func() {
144 defer close(ch)
145 ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
146 defer cancel()
147 info, _ := FetchChangelog(ctx, version)
148 ch <- info
149 }()
150 return ch
151}
152
153// ShouldShowChangelog reports whether the running binary version
154// differs from the last version whose changelog the user dismissed.
155// Returns false on development builds and on the first-ever launch (no
156// LastChangelogShown stored — we don't dump release notes at someone who
157// just installed).
158func ShouldShowChangelog(currentVersion string, cfg Config) bool {
159 if isDevVersion(currentVersion) {
160 return false
161 }
162 currentVersion = semverOnly(currentVersion)
163 if cfg.LastChangelogShown == "" {
164 return false
165 }
166 return semverOnly(cfg.LastChangelogShown) != currentVersion
167}
168
169// MarkChangelogShown persists the version whose changelog the user
170// just dismissed. Idempotent; safe to call when the dialog wasn't
171// actually shown (e.g. fetch failed) so we don't keep retrying.
172func MarkChangelogShown(version string) error {
173 v := semverOnly(version)
174 cfg, _ := LoadConfig()
175 if semverOnly(cfg.LastChangelogShown) == v {
176 return nil
177 }
178 cfg.LastChangelogShown = v
179 return SaveConfig(cfg)
180}
181
182// SeedChangelogVersion sets LastChangelogShown if it's currently
183// empty. Called once on first-ever launch so future upgrades
184// correctly trigger the dialog while THIS launch (which is also
185// "first-ever") doesn't.
186func SeedChangelogVersion(version string) {
187 if isDevVersion(version) {
188 return
189 }
190 version = semverOnly(version)
191 cfg, err := LoadConfig()
192 if err != nil {
193 return
194 }
195 if cfg.LastChangelogShown != "" {
196 return
197 }
198 cfg.LastChangelogShown = version
199 _ = SaveConfig(cfg)
200}