carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package changelog
2
3import (
4 "regexp"
5 "sort"
6 "strconv"
7 "strings"
8)
9
10var branchRes = []*regexp.Regexp{
11 regexp.MustCompile(`(?m)Squashed from (\S+) into`),
12 regexp.MustCompile(`(?m)^Merge branch '([^']+)'(?: into \S+)?$`),
13 regexp.MustCompile(`(?m)^Merge pull request #\d+ from [^/\s]+/(\S+)`),
14}
15
16// BranchName is the branch a first-parent commit landed, read from the
17// message Amadan's squash, Amadan's merge or GitHub's merge button wrote,
18// or "" for a commit made on main directly.
19func BranchName(message string) string {
20 for _, re := range branchRes {
21 if m := re.FindStringSubmatch(message); m != nil {
22 return m[1]
23 }
24 }
25 return ""
26}
27
28var refRes = []*regexp.Regexp{
29 regexp.MustCompile(`#(\d+)\b`),
30 regexp.MustCompile(`(?i)\bdiscussion (\d+)\b`),
31}
32
33// DiscussionRefs is every number a message points at as a discussion.
34// Some are GitHub issue or PR numbers from before Amadan; Build keeps
35// only those Amadan knows and created before the commit.
36func DiscussionRefs(message string) []int {
37 seen := map[int]bool{}
38 for _, re := range refRes {
39 for _, m := range re.FindAllStringSubmatch(message, -1) {
40 if n, err := strconv.Atoi(m[1]); err == nil && n > 0 {
41 seen[n] = true
42 }
43 }
44 }
45 out := make([]int, 0, len(seen))
46 for n := range seen {
47 out = append(out, n)
48 }
49 sort.Ints(out)
50 return out
51}
52
53// Notes is a release's published notes, in one of two shapes.
54type Notes struct {
55 // Form is "bullets" for the v0.19.0-on shape, which the page renders
56 // as lists with links per bullet, or "markdown" for older free-form
57 // notes, rendered whole.
58 Form string `json:"form"`
59 Sections []NoteSection `json:"sections,omitempty"`
60 MD string `json:"md,omitempty"`
61}
62
63type NoteSection struct {
64 Title string `json:"title"`
65 Bullets []Bullet `json:"bullets"`
66}
67
68type Bullet struct {
69 MD string `json:"md"`
70 // Items is the bullet's nested list, one markdown string per item, in
71 // order. It is optional: a reader that predates it renders MD alone.
72 Items []string `json:"items,omitempty"`
73 Lead string `json:"-"`
74 Branches []string `json:"branches,omitempty"`
75 Docs []string `json:"docs,omitempty"`
76}
77
78var (
79 headerLineRe = regexp.MustCompile(`^carlos v\d+\.\d+\.\d+ \(platform `)
80 sectionRe = regexp.MustCompile(`^\*\*([^*]+)\*\*\s*$`)
81 leadRe = regexp.MustCompile(`^\*\*(.+?)\*\*`)
82 headingRe = regexp.MustCompile(`^(#{1,6})(\s)`)
83 nestedItemRe = regexp.MustCompile(`^(?: +|\t+)[-*+]\s+(.*)$`)
84)
85
86// ParseNotes reads a release body. The boilerplate every release repeats
87// (the header line, the download-verification line, an Install section,
88// a "Generated with Claude Code" footer) is dropped; the page links the
89// published release for those.
90func ParseNotes(body string) *Notes {
91 body = strings.ReplaceAll(body, "\r\n", "\n")
92 lines := strings.Split(body, "\n")
93 first := ""
94 for _, l := range lines {
95 if strings.TrimSpace(l) != "" {
96 first = strings.TrimSpace(l)
97 break
98 }
99 }
100 if headerLineRe.MatchString(first) && strings.Contains(body, "\n- **") {
101 return parseBullets(lines)
102 }
103 return &Notes{Form: "markdown", MD: parseMarkdown(lines)}
104}
105
106func boilerplate(l string) bool {
107 t := strings.TrimSpace(l)
108 return headerLineRe.MatchString(t) ||
109 strings.HasPrefix(t, "Verify a download against") ||
110 strings.HasPrefix(t, "đŸ¤– Generated with")
111}
112
113func parseBullets(lines []string) *Notes {
114 n := &Notes{Form: "bullets"}
115 cur := &NoteSection{}
116 var b *Bullet
117 // gap is a blank line since b's last line. The bullet stays open
118 // across it, so an indented list after the blank still nests under it;
119 // any other line after a gap closes the bullet, as a blank line always
120 // did. inItem is whether b's last line belongs to its last nested item.
121 gap, inItem := false, false
122 flush := func() {
123 if b != nil {
124 b.MD = strings.TrimSpace(b.MD)
125 for i := range b.Items {
126 b.Items[i] = strings.TrimSpace(b.Items[i])
127 }
128 cur.Bullets = append(cur.Bullets, *b)
129 b = nil
130 }
131 gap, inItem = false, false
132 }
133 for _, l := range lines {
134 t := strings.TrimSpace(l)
135 switch {
136 case boilerplate(l):
137 flush()
138 case strings.HasPrefix(l, "- "):
139 flush()
140 md := strings.TrimSpace(l[2:])
141 b = &Bullet{MD: md}
142 if m := leadRe.FindStringSubmatch(md); m != nil {
143 b.Lead = m[1]
144 }
145 case b != nil && nestedItemRe.MatchString(l):
146 b.Items = append(b.Items, nestedItemRe.FindStringSubmatch(l)[1])
147 gap, inItem = false, true
148 case sectionRe.MatchString(t):
149 flush()
150 if len(cur.Bullets) > 0 || cur.Title != "" {
151 n.Sections = append(n.Sections, *cur)
152 }
153 cur = &NoteSection{Title: sectionRe.FindStringSubmatch(t)[1]}
154 case t == "":
155 if b != nil {
156 gap = true
157 }
158 case b != nil && gap:
159 flush()
160 case b != nil && inItem:
161 b.Items[len(b.Items)-1] += "\n" + t
162 case b != nil:
163 b.MD += "\n" + t
164 }
165 }
166 flush()
167 if len(cur.Bullets) > 0 || cur.Title != "" {
168 n.Sections = append(n.Sections, *cur)
169 }
170 return n
171}
172
173// parseMarkdown keeps an older body whole, minus the boilerplate, with
174// its headings demoted two levels: on the page the release itself is the
175// h2, so the notes' own "##" becomes an h4. Headings inside fences are
176// left alone.
177func parseMarkdown(lines []string) string {
178 var out []string
179 inFence, skipping := "", false
180 for _, l := range lines {
181 t := strings.TrimSpace(l)
182 if inFence == "" && (strings.HasPrefix(t, "```") || strings.HasPrefix(t, "~~~")) {
183 inFence = t[:3]
184 } else if inFence != "" && strings.HasPrefix(t, inFence) {
185 inFence = ""
186 if !skipping {
187 out = append(out, l)
188 }
189 continue
190 }
191 if inFence == "" {
192 if m := headingRe.FindStringSubmatch(l); m != nil {
193 skipping = strings.EqualFold(strings.TrimSpace(l[len(m[1]):]), "Install")
194 if !skipping {
195 level := len(m[1]) + 2
196 if level > 6 {
197 level = 6
198 }
199 l = strings.Repeat("#", level) + l[len(m[1]):]
200 }
201 }
202 if !skipping && boilerplate(l) {
203 continue
204 }
205 }
206 if !skipping {
207 out = append(out, l)
208 }
209 }
210 return strings.TrimSpace(strings.Join(out, "\n"))
211}
212
213var codeSpanRe = regexp.MustCompile("`(carlos [^`]+)`")
214
215// CLIDocLinks links each `carlos …` code span in md to the CLI page's
216// most specific heading that prefixes it, word by word. cliHeadings maps
217// heading text ("carlos store scan") to its anchor.
218func CLIDocLinks(md string, cliHeadings map[string]string) []string {
219 var out []string
220 seen := map[string]bool{}
221 for _, m := range codeSpanRe.FindAllStringSubmatch(md, -1) {
222 words := strings.Fields(m[1])
223 for n := len(words); n >= 2; n-- {
224 if a, ok := cliHeadings[strings.Join(words[:n], " ")]; ok {
225 link := "/docs/cli#" + a
226 if !seen[link] {
227 seen[link] = true
228 out = append(out, link)
229 }
230 break
231 }
232 }
233 }
234 return out
235}
236