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