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 version = semverOnly(version)
40 if version == "" || version == "dev" {
41 return ChangelogInfo{}, nil
42 }
43
44 // For local dev builds (0.0.0), fetch the latest release instead
45 // of a tagged one so developers always see the newest changelog.
46 var url string
47 if version == "0.0.0" {
48 url = "https://api.github.com/repos/patriceckhart/zot/releases/latest"
49 } else {
50 tag := version
51 if !strings.HasPrefix(tag, "v") {
52 tag = "v" + tag
53 }
54 url = fmt.Sprintf("https://api.github.com/repos/patriceckhart/zot/releases/tags/%s", tag)
55 }
56 req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
57 if err != nil {
58 return ChangelogInfo{}, err
59 }
60 req.Header.Set("accept", "application/vnd.github+json")
61 req.Header.Set("x-github-api-version", "2022-11-28")
62 if tok := os.Getenv("GITHUB_TOKEN"); tok != "" {
63 req.Header.Set("authorization", "Bearer "+tok)
64 }
65
66 client := &http.Client{Timeout: 4 * time.Second}
67 resp, err := client.Do(req)
68 if err != nil {
69 return ChangelogInfo{}, err
70 }
71 defer resp.Body.Close()
72 if resp.StatusCode != 200 {
73 return ChangelogInfo{}, fmt.Errorf("github api %d", resp.StatusCode)
74 }
75
76 var body struct {
77 TagName string `json:"tag_name"`
78 HTMLURL string `json:"html_url"`
79 Body string `json:"body"`
80 }
81 if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
82 return ChangelogInfo{}, err
83 }
84 body.Body = strings.TrimSpace(body.Body)
85 if body.Body == "" {
86 return ChangelogInfo{}, nil
87 }
88 // Extract only the changelog section and strip markdown headers.
89 body.Body = extractChangelog(body.Body)
90 if body.Body == "" {
91 return ChangelogInfo{}, nil
92 }
93 return ChangelogInfo{
94 Version: strings.TrimPrefix(body.TagName, "v"),
95 Body: body.Body,
96 URL: body.HTMLURL,
97 }, nil
98}
99
100// extractChangelog pulls the content starting from "## Changelog"
101// (or the whole body if no such header exists) and strips markdown
102// heading markers (# ## ###) from every line so the TUI renders
103// clean text.
104func extractChangelog(body string) string {
105 lines := strings.Split(body, "\n")
106
107 // Find the "## Changelog" line.
108 start := -1
109 for i, l := range lines {
110 trimmed := strings.TrimSpace(l)
111 if strings.EqualFold(trimmed, "## changelog") ||
112 strings.EqualFold(trimmed, "## Changelog") {
113 start = i + 1
114 break
115 }
116 }
117 if start < 0 {
118 // No changelog header found; use the whole body.
119 start = 0
120 }
121
122 // Process remaining lines: strip # markers but mark headings
123 // so the renderer can color them.
124 var out []string
125 for _, l := range lines[start:] {
126 trimmed := strings.TrimSpace(l)
127 if trimmed == "" {
128 out = append(out, "")
129 continue
130 }
131 // Detect markdown headings and strip the # but keep text.
132 if strings.HasPrefix(trimmed, "#") {
133 heading := strings.TrimLeft(trimmed, "#")
134 heading = strings.TrimSpace(heading)
135 if heading != "" {
136 // Mark headings with a sentinel the dialog can detect.
137 out = append(out, "\x00H:"+heading)
138 }
139 continue
140 }
141 out = append(out, l)
142 }
143 return strings.TrimSpace(strings.Join(out, "\n"))
144}
145
146// FetchChangelogAsync runs FetchChangelog on a goroutine and delivers
147// the result on the returned channel. Channel always closes.
148func FetchChangelogAsync(version string) <-chan ChangelogInfo {
149 ch := make(chan ChangelogInfo, 1)
150 go func() {
151 defer close(ch)
152 ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
153 defer cancel()
154 info, _ := FetchChangelog(ctx, version)
155 ch <- info
156 }()
157 return ch
158}
159
160// ShouldShowChangelog reports whether the running binary version
161// differs from the last version whose changelog the user dismissed.
162// Returns false on dev builds (version "" / "dev" / "0.0.0") and on
163// the first-ever launch (no LastChangelogShown stored — we don't
164// dump release notes at someone who just installed).
165func ShouldShowChangelog(currentVersion string, cfg Config) bool {
166 currentVersion = semverOnly(currentVersion)
167 if currentVersion == "" || currentVersion == "dev" {
168 return false
169 }
170 if cfg.LastChangelogShown == "" {
171 return false
172 }
173 if currentVersion == "0.0.0" {
174 return true
175 }
176 return semverOnly(cfg.LastChangelogShown) != currentVersion
177}
178
179// MarkChangelogShown persists the version whose changelog the user
180// just dismissed. Idempotent; safe to call when the dialog wasn't
181// actually shown (e.g. fetch failed) so we don't keep retrying.
182func MarkChangelogShown(version string) error {
183 v := semverOnly(version)
184 cfg, _ := LoadConfig()
185 if semverOnly(cfg.LastChangelogShown) == v {
186 return nil
187 }
188 cfg.LastChangelogShown = v
189 return SaveConfig(cfg)
190}
191
192// SeedChangelogVersion sets LastChangelogShown if it's currently
193// empty. Called once on first-ever launch so future upgrades
194// correctly trigger the dialog while THIS launch (which is also
195// "first-ever") doesn't.
196func SeedChangelogVersion(version string) {
197 version = semverOnly(version)
198 if version == "" || version == "dev" {
199 return
200 }
201 cfg, err := LoadConfig()
202 if err != nil {
203 return
204 }
205 if cfg.LastChangelogShown != "" {
206 return
207 }
208 cfg.LastChangelogShown = version
209 _ = SaveConfig(cfg)
210}