package changelog import ( "regexp" "sort" "strconv" "strings" ) var branchRes = []*regexp.Regexp{ regexp.MustCompile(`(?m)Squashed from (\S+) into`), regexp.MustCompile(`(?m)^Merge branch '([^']+)'(?: into \S+)?$`), regexp.MustCompile(`(?m)^Merge pull request #\d+ from [^/\s]+/(\S+)`), } // BranchName is the branch a first-parent commit landed, read from the // message Amadan's squash, Amadan's merge or GitHub's merge button wrote, // or "" for a commit made on main directly. func BranchName(message string) string { for _, re := range branchRes { if m := re.FindStringSubmatch(message); m != nil { return m[1] } } return "" } var refRes = []*regexp.Regexp{ regexp.MustCompile(`#(\d+)\b`), regexp.MustCompile(`(?i)\bdiscussion (\d+)\b`), } // DiscussionRefs is every number a message points at as a discussion. // Some are GitHub issue or PR numbers from before Amadan; Build keeps // only those Amadan knows and created before the commit. func DiscussionRefs(message string) []int { seen := map[int]bool{} for _, re := range refRes { for _, m := range re.FindAllStringSubmatch(message, -1) { if n, err := strconv.Atoi(m[1]); err == nil && n > 0 { seen[n] = true } } } out := make([]int, 0, len(seen)) for n := range seen { out = append(out, n) } sort.Ints(out) return out } // Notes is a release's published notes, in one of two shapes. type Notes struct { // Form is "bullets" for the v0.19.0-on shape, which the page renders // as lists with links per bullet, or "markdown" for older free-form // notes, rendered whole. Form string `json:"form"` Sections []NoteSection `json:"sections,omitempty"` MD string `json:"md,omitempty"` } type NoteSection struct { Title string `json:"title"` Bullets []Bullet `json:"bullets"` } type Bullet struct { MD string `json:"md"` // Items is the bullet's nested list, one markdown string per item, in // order. It is optional: a reader that predates it renders MD alone. Items []string `json:"items,omitempty"` Lead string `json:"-"` Branches []string `json:"branches,omitempty"` Docs []string `json:"docs,omitempty"` } var ( headerLineRe = regexp.MustCompile(`^carlos v\d+\.\d+\.\d+ \(platform `) sectionRe = regexp.MustCompile(`^\*\*([^*]+)\*\*\s*$`) leadRe = regexp.MustCompile(`^\*\*(.+?)\*\*`) headingRe = regexp.MustCompile(`^(#{1,6})(\s)`) nestedItemRe = regexp.MustCompile(`^(?: +|\t+)[-*+]\s+(.*)$`) ) // ParseNotes reads a release body. The boilerplate every release repeats // (the header line, the download-verification line, an Install section, // a "Generated with Claude Code" footer) is dropped; the page links the // published release for those. func ParseNotes(body string) *Notes { body = strings.ReplaceAll(body, "\r\n", "\n") lines := strings.Split(body, "\n") first := "" for _, l := range lines { if strings.TrimSpace(l) != "" { first = strings.TrimSpace(l) break } } if headerLineRe.MatchString(first) && strings.Contains(body, "\n- **") { return parseBullets(lines) } return &Notes{Form: "markdown", MD: parseMarkdown(lines)} } func boilerplate(l string) bool { t := strings.TrimSpace(l) return headerLineRe.MatchString(t) || strings.HasPrefix(t, "Verify a download against") || strings.HasPrefix(t, "🤖 Generated with") } func parseBullets(lines []string) *Notes { n := &Notes{Form: "bullets"} cur := &NoteSection{} var b *Bullet // gap is a blank line since b's last line. The bullet stays open // across it, so an indented list after the blank still nests under it; // any other line after a gap closes the bullet, as a blank line always // did. inItem is whether b's last line belongs to its last nested item. gap, inItem := false, false flush := func() { if b != nil { b.MD = strings.TrimSpace(b.MD) for i := range b.Items { b.Items[i] = strings.TrimSpace(b.Items[i]) } cur.Bullets = append(cur.Bullets, *b) b = nil } gap, inItem = false, false } for _, l := range lines { t := strings.TrimSpace(l) switch { case boilerplate(l): flush() case strings.HasPrefix(l, "- "): flush() md := strings.TrimSpace(l[2:]) b = &Bullet{MD: md} if m := leadRe.FindStringSubmatch(md); m != nil { b.Lead = m[1] } case b != nil && nestedItemRe.MatchString(l): b.Items = append(b.Items, nestedItemRe.FindStringSubmatch(l)[1]) gap, inItem = false, true case sectionRe.MatchString(t): flush() if len(cur.Bullets) > 0 || cur.Title != "" { n.Sections = append(n.Sections, *cur) } cur = &NoteSection{Title: sectionRe.FindStringSubmatch(t)[1]} case t == "": if b != nil { gap = true } case b != nil && gap: flush() case b != nil && inItem: b.Items[len(b.Items)-1] += "\n" + t case b != nil: b.MD += "\n" + t } } flush() if len(cur.Bullets) > 0 || cur.Title != "" { n.Sections = append(n.Sections, *cur) } return n } // parseMarkdown keeps an older body whole, minus the boilerplate, with // its headings demoted two levels: on the page the release itself is the // h2, so the notes' own "##" becomes an h4. Headings inside fences are // left alone. func parseMarkdown(lines []string) string { var out []string inFence, skipping := "", false for _, l := range lines { t := strings.TrimSpace(l) if inFence == "" && (strings.HasPrefix(t, "```") || strings.HasPrefix(t, "~~~")) { inFence = t[:3] } else if inFence != "" && strings.HasPrefix(t, inFence) { inFence = "" if !skipping { out = append(out, l) } continue } if inFence == "" { if m := headingRe.FindStringSubmatch(l); m != nil { skipping = strings.EqualFold(strings.TrimSpace(l[len(m[1]):]), "Install") if !skipping { level := len(m[1]) + 2 if level > 6 { level = 6 } l = strings.Repeat("#", level) + l[len(m[1]):] } } if !skipping && boilerplate(l) { continue } } if !skipping { out = append(out, l) } } return strings.TrimSpace(strings.Join(out, "\n")) } var codeSpanRe = regexp.MustCompile("`(carlos [^`]+)`") // CLIDocLinks links each `carlos …` code span in md to the CLI page's // most specific heading that prefixes it, word by word. cliHeadings maps // heading text ("carlos store scan") to its anchor. func CLIDocLinks(md string, cliHeadings map[string]string) []string { var out []string seen := map[string]bool{} for _, m := range codeSpanRe.FindAllStringSubmatch(md, -1) { words := strings.Fields(m[1]) for n := len(words); n >= 2; n-- { if a, ok := cliHeadings[strings.Join(words[:n], " ")]; ok { link := "/docs/cli#" + a if !seen[link] { seen[link] = true out = append(out, link) } break } } } return out }