package changelog import ( "encoding/json" "fmt" "html" "net/url" "path" "regexp" "slices" "sort" "strconv" "strings" "time" "github.com/carlosframework/platform/internal/docsite" ) // RepoURL is the public Amadan repo every branch, commit and discussion // link points into. const RepoURL = "https://amadan.net/carlos/platform" // NotesBase is where each published release's notes live. const NotesBase = "https://github.com/carlosframework/releases/releases/tag/" type Tag struct { Name string SHA string Date time.Time } type Commit struct { SHA string Parents []string AuthorName, AuthorEmail string Date time.Time // committer date Message string } // DocsChange is one docs/site page a commit added lines to: the page's // source as of the commit, and the 1-based new-file lines added. type DocsChange struct { Page string // slug, e.g. "cli" Body string Added []int } type Git interface { Tags() ([]Tag, error) // FirstParent lists the commits on to's first-parent line that no // excluded tag reaches, newest first. FirstParent(to string, exclude []string) ([]Commit, error) // Range lists the non-merge commits in from..to, newest first. Range(from, to string) ([]Commit, error) // Tombstone is the last tip Amadan kept for a merged branch, at // refs/amadan/closed/. Tombstone(branch string) (string, time.Time, bool) // LiveBranch reports whether origin still carries the branch. LiveBranch(branch string) bool DocsChanges(c Commit) ([]DocsChange, error) } type Releases interface { // Body is a tag's published release notes; ok is false when the tag // was never published. Body(tag string) (body string, ok bool, err error) } type Discussion struct { ID int `json:"id"` Title string `json:"title"` State string `json:"state"` Created int64 `json:"-"` } type Discussions interface { // Show returns nil, nil when there is no such discussion. Show(id int) (*Discussion, error) ForBranch(branch string) ([]Discussion, error) } type Sources struct { Git Git Releases Releases Discussions Discussions } // Links is changelog-links.json: tag, then a bullet's bold lead, then the // branches behind that bullet. type Links map[string]map[string][]string type Changelog struct { Note string `json:"//"` Repo string `json:"repo"` Releases []Release `json:"releases"` } type Release struct { Tag string `json:"tag"` Date string `json:"date"` SHA string `json:"sha"` Published bool `json:"published"` NotesURL string `json:"notes_url,omitempty"` Notes *Notes `json:"notes"` Changes []Change `json:"changes"` } type Change struct { SHA string `json:"sha"` Date string `json:"date"` Subject string `json:"subject"` Branch string `json:"branch,omitempty"` BranchURL string `json:"branch_url,omitempty"` Commits int `json:"commits"` Discussions []Discussion `json:"discussions,omitempty"` Docs []string `json:"docs,omitempty"` Credit []Credit `json:"credit"` } const generatedNote = "Generated by go run ./cmd/changelog from git, the published release notes and Amadan. Do not edit; regenerate." // maxDocs caps the docs links one change or bullet shows. const maxDocs = 5 // refSlack allows for clock skew between the machine that committed and // the hub that stamped a discussion's creation. const refSlack = time.Hour // Build joins every source into the changelog, newest release first. func Build(src Sources, people People, links Links, site *docsite.Site) (*Changelog, error) { tags, err := src.Git.Tags() if err != nil { return nil, err } sort.Slice(tags, func(i, j int) bool { return semverLess(tags[i].Name, tags[j].Name) }) b := &builder{src: src, people: people, site: site, discussions: map[int]*Discussion{}, landed: map[string]bool{}, built: map[string]*Change{}} b.cliHeadings = map[string]string{} if cli := site.BySlug["cli"]; cli != nil { for _, h := range cli.Headings { if anchored(h) { b.cliHeadings[h.Text] = h.Anchor } } } out := &Changelog{Note: generatedNote, Repo: RepoURL} used := map[string]map[string]bool{} for _, t := range tags { r, err := b.release(t, out.Releases) if err != nil { return nil, fmt.Errorf("%s: %w", t.Name, err) } used[t.Name] = applyLinks(r, links[t.Name]) out.Releases = append(out.Releases, *r) } for tag, byLead := range links { for lead, branches := range byLead { if !used[tag][lead] { return nil, fmt.Errorf("changelog-links.json: %s has no bullet whose lead is %q", tag, lead) } if err := checkLinkBranches(out, tag, branches); err != nil { return nil, err } } } for i, j := 0, len(out.Releases)-1; i < j; i, j = i+1, j-1 { out.Releases[i], out.Releases[j] = out.Releases[j], out.Releases[i] } if err := CheckPublic(out, people); err != nil { return nil, err } return out, nil } type builder struct { src Sources people People site *docsite.Site cliHeadings map[string]string discussions map[int]*Discussion // landed is every commit an earlier change already accounted for, // so a branch stacked on one squashed before it does not count the // earlier branch's commits again. landed map[string]bool // built is every change already joined, by its landing commit: an // unpublished tag and the published release after it can list the // same commit. built map[string]*Change } // RangeBases is the tags a release's changes are counted from. A // published release lists what no earlier published release carried, so // it owns the work of an unpublished tag before it (v0.22.2 shipped // v0.22.1's change); an unpublished tag lists what no earlier tag of any // kind carried. earlier is in semver order. func RangeBases(published bool, earlier []Release) []string { var out []string for _, r := range earlier { if r.Published || !published { out = append(out, r.Tag) } } return out } func (b *builder) release(t Tag, earlier []Release) (*Release, error) { r := &Release{Tag: t.Name, Date: day(t.Date), SHA: t.SHA, Changes: []Change{}} body, ok, err := b.src.Releases.Body(t.Name) if err != nil { return nil, err } if ok { r.Published = true r.NotesURL = NotesBase + t.Name // A release published with an empty body (v0.18.0) has a notes // page to link and nothing to show here. if n := ParseNotes(body); n.MD != "" || len(n.Sections) > 0 { r.Notes = n } for si := range notesSections(r.Notes) { for bi := range r.Notes.Sections[si].Bullets { bl := &r.Notes.Sections[si].Bullets[bi] bl.Docs = capped(CLIDocLinks(strings.Join(append([]string{bl.MD}, bl.Items...), "\n"), b.cliHeadings)) } } } commits, err := b.src.Git.FirstParent(t.Name, RangeBases(r.Published, earlier)) if err != nil { return nil, err } // Oldest first, so the landed set is complete for each change; the // page lists them newest first. for i := len(commits) - 1; i >= 0; i-- { ch, err := b.change(commits[i]) if err != nil { return nil, fmt.Errorf("%s: %w", short(commits[i].SHA), err) } r.Changes = append(r.Changes, *ch) } for i, j := 0, len(r.Changes)-1; i < j; i, j = i+1, j-1 { r.Changes[i], r.Changes[j] = r.Changes[j], r.Changes[i] } return r, nil } func notesSections(n *Notes) []NoteSection { if n == nil { return nil } return n.Sections } func (b *builder) change(c Commit) (*Change, error) { if ch, ok := b.built[c.SHA]; ok { return ch, nil } ch := &Change{ SHA: c.SHA, Date: day(c.Date), Branch: BranchName(c.Message), } b.built[c.SHA] = ch own, onAmadan, err := b.ownCommits(c, ch.Branch) if err != nil { return nil, err } ch.Subject = Redact(Describe(c.Message, ch.Branch, own)) b.landed[c.SHA] = true for _, o := range own { b.landed[o.SHA] = true } if onAmadan { ch.BranchURL = RepoURL + "/-/branch/" + url.PathEscape(ch.Branch) } ch.Commits = len(own) idents := make([]CommitIdent, 0, len(own)) for _, o := range own { idents = append(idents, CommitIdent{o.AuthorName, o.AuthorEmail, o.Message}) } ch.Credit, err = Tally(idents, b.people) if err != nil { return nil, err } if len(ch.Credit) == 0 { // Every own commit was a skipped identity (GitHub's merge // button); the landing commit is the evidence left. ch.Credit, err = Tally([]CommitIdent{{c.AuthorName, c.AuthorEmail, c.Message}}, b.people) if err != nil { return nil, err } } ch.Discussions, err = b.discussionsFor(c, own, ch.Branch, onAmadan) if err != nil { return nil, err } docs, err := b.src.Git.DocsChanges(c) if err != nil { return nil, err } ch.Docs = capped(b.docsLinks(docs)) return ch, nil } // ownCommits is the work a first-parent commit landed. onAmadan reports // whether the branch has a page on Amadan to link to. func (b *builder) ownCommits(c Commit, branch string) ([]Commit, bool, error) { onAmadan := false if branch != "" { onAmadan = b.src.Git.LiveBranch(branch) || strings.Contains(c.Message, "Squashed from "+branch+" into") } if len(c.Parents) == 2 { own, err := b.src.Git.Range(c.Parents[0], c.Parents[1]) if err != nil { return nil, false, err } own = b.unlanded(own) if branch != "" { if _, _, ok := b.src.Git.Tombstone(branch); ok { onAmadan = true } } return own, onAmadan, nil } if branch != "" && len(c.Parents) == 1 { if tip, when, ok := b.src.Git.Tombstone(branch); ok { onAmadan = true // A tombstone newer than the squash belongs to a later // branch that reused the name; its commits are not this // change's. if !when.After(c.Date.Add(refSlack)) { own, err := b.src.Git.Range(c.Parents[0], tip) if err != nil { return nil, false, err } if own = b.unlanded(own); len(own) > 0 { return own, onAmadan, nil } } } } return []Commit{c}, onAmadan, nil } func (b *builder) unlanded(cs []Commit) []Commit { out := cs[:0:0] for _, c := range cs { if !b.landed[c.SHA] { out = append(out, c) } } return out } // anchored reports whether the website gives a heading an id: its // markdown-it-anchor configuration covers levels two to four. func anchored(h docsite.Heading) bool { return h.Level >= 2 && h.Level <= 4 } func (b *builder) discussionsFor(c Commit, own []Commit, branch string, onAmadan bool) ([]Discussion, error) { found := map[int]Discussion{} consider := func(msg string, at time.Time) error { for _, id := range DiscussionRefs(msg) { if _, ok := found[id]; ok { continue } d, err := b.show(id) if err != nil { return err } if d != nil && time.Unix(d.Created, 0).Before(at.Add(refSlack)) { found[id] = *d } } return nil } if err := consider(c.Message, c.Date); err != nil { return nil, err } for _, o := range own { if o.SHA == c.SHA { continue } if err := consider(o.Message, o.Date); err != nil { return nil, err } } if onAmadan { list, err := b.src.Discussions.ForBranch(branch) if err != nil { return nil, err } for _, d := range list { d.Title = Redact(d.Title) found[d.ID] = d } } if len(found) == 0 { return nil, nil } out := make([]Discussion, 0, len(found)) for _, d := range found { out = append(out, d) } sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) return out, nil } func (b *builder) show(id int) (*Discussion, error) { if d, ok := b.discussions[id]; ok { return d, nil } d, err := b.src.Discussions.Show(id) if err != nil { return nil, err } if d != nil { d.Title = Redact(d.Title) } b.discussions[id] = d return d, nil } // docsLinks maps each added line to the nearest heading above it (level // two or deeper) in the page as the commit left it, and keeps the link // only if today's docs still have that page and heading. func (b *builder) docsLinks(changes []DocsChange) []string { var out []string seen := map[string]bool{} add := func(l string) { if !seen[l] { seen[l] = true out = append(out, l) } } for _, dc := range changes { today := b.site.BySlug[dc.Page] if today == nil { continue } anchors := map[string]bool{} for _, h := range today.Headings { if anchored(h) { anchors[h.Anchor] = true } } then := docsite.Parse(dc.Page, dc.Body) for _, line := range dc.Added { anchor := "" for _, h := range then.Headings { if h.Line > line { break } switch { case anchored(h): anchor = h.Anchor case h.Level == 1: anchor = "" } } if anchor != "" && anchors[anchor] { add(pageURL(dc.Page) + "#" + anchor) } else if anchor == "" { add(pageURL(dc.Page)) } } } // A page link says less than a section link on the same page. kept := out[:0:0] for _, l := range out { if !strings.Contains(l, "#") && slices.ContainsFunc(out, func(o string) bool { return strings.HasPrefix(o, l+"#") }) { continue } kept = append(kept, l) } return kept } func pageURL(slug string) string { if slug == "index" { return "/docs/" } return "/docs/" + slug } // applyLinks sets each bullet's branches from the hand-kept map and // reports which leads it used. func applyLinks(r *Release, byLead map[string][]string) map[string]bool { used := map[string]bool{} if r.Notes == nil { return used } for si := range r.Notes.Sections { for bi := range r.Notes.Sections[si].Bullets { bl := &r.Notes.Sections[si].Bullets[bi] if br, ok := byLead[bl.Lead]; ok && bl.Lead != "" { bl.Branches = br used[bl.Lead] = true } } } return used } func checkLinkBranches(c *Changelog, tag string, branches []string) error { for _, r := range c.Releases { if r.Tag != tag { continue } have := map[string]bool{} for _, ch := range r.Changes { if ch.Branch != "" { have[ch.Branch] = true } } for _, br := range branches { if !have[br] { return fmt.Errorf("changelog-links.json: %s links %q, which did not land in that release", tag, br) } } } return nil } var ( addressRe = regexp.MustCompile(`(?i)[a-z0-9._%+~-]+@[a-z0-9-]+(\.[a-z0-9-]+)*\.[a-z]{2,}`) keymailRe = regexp.MustCompile(`(?i)[a-z0-9._-]+~[a-z0-9._-]+@[a-z0-9.-]+`) // privateRe is every link into a repo a visitor cannot open: the // platform's GitHub mirror is private, and so are the five source // products' repos. The public ones (releases, homebrew-tap, skills, // website) are allowed. privateRe = regexp.MustCompile(`(?i)github\.com/carlosframework/(platform|platform-infrastructure|carloku[a-z-]*)\b`) ) var privateURLRe = regexp.MustCompile(`(?i)(https?://)?github\.com/carlosframework/(platform|platform-infrastructure|carloku[a-z-]*)\b\S*`) // Redact replaces every address in a record the page quotes verbatim (a // commit subject, a discussion title) with "[address]". Amadan shows the // original; the changelog does not repeat it. // // A link into a private repo becomes "[private repo]" the same way. func Redact(s string) string { s = keymailRe.ReplaceAllString(s, "[address]") s = addressRe.ReplaceAllString(s, "[address]") return privateURLRe.ReplaceAllString(s, "[private repo]") } // CheckPublic fails if the changelog would publish something a public // page must not: an email address of any kind (every address people.json // names, anything shaped like one, an agent account), or a link into a // private repo. The generator runs it before writing and the gate runs // it over the committed file. func CheckPublic(c *Changelog, people People) error { for _, r := range c.Releases { if r.Notes != nil { if err := checkPublicValue(r.Notes, people); err != nil { return fmt.Errorf("%s notes: %w", r.Tag, err) } } for _, ch := range r.Changes { if err := checkPublicValue(ch, people); err != nil { return fmt.Errorf("%s change %s (%s): %w", r.Tag, short(ch.SHA), ch.Subject, err) } } } return checkPublicValue(c, people) } func checkPublicValue(v any, people People) error { raw, err := json.Marshal(v) if err != nil { return err } var tree any if err := json.Unmarshal(raw, &tree); err != nil { return err } var texts []string collectStrings(tree, &texts) for _, t := range texts { for _, form := range decodedForms(t) { if err := checkPublicText(strings.ToLower(form), people); err != nil { return err } } } return nil } func collectStrings(v any, out *[]string) { switch x := v.(type) { case string: *out = append(*out, x) case []any: for _, e := range x { collectStrings(e, out) } case map[string]any: for _, e := range x { collectStrings(e, out) } } } var ( mdEscapeRe = regexp.MustCompile(`\\([!-/:-@\[-` + "`" + `{-~])`) percentRe = regexp.MustCompile(`%[0-9a-fA-F]{2}`) ) // decodedForms is a string as stored and as a reader would see it once // markdown and the browser have decoded it: HTML entities, markdown // backslash escapes and percent-encoding undone, each alone and all // together. "ops@example.org" is an address on the page. func decodedForms(s string) []string { ent := html.UnescapeString(s) md := mdEscapeRe.ReplaceAllString(s, "$1") pct := percentRe.ReplaceAllStringFunc(s, func(m string) string { if b, err := strconv.ParseUint(m[1:], 16, 8); err == nil { return string(rune(b)) } return m }) all := percentRe.ReplaceAllStringFunc(mdEscapeRe.ReplaceAllString(html.UnescapeString(html.UnescapeString(s)), "$1"), func(m string) string { if b, err := strconv.ParseUint(m[1:], 16, 8); err == nil { return string(rune(b)) } return m }) // What a browser makes of markdown emphasis and dot segments: // "**ops@example**.org" reads as an address, and // "carlosframework/./platform" resolves to the private repo. plain := githubPathRe.ReplaceAllStringFunc(emphasisRe.ReplaceAllString(all, ""), func(m string) string { return "github.com" + path.Clean(m[len("github.com"):]) }) return []string{s, ent, md, pct, all, plain} } var ( emphasisRe = regexp.MustCompile("[*_`~]+") githubPathRe = regexp.MustCompile(`(?i)github\.com/[^\s)"'<>\]]*`) ) func checkPublicText(s string, people People) error { for addr := range people { if strings.Contains(addr, "@") && strings.Contains(s, strings.ToLower(addr)) { return fmt.Errorf("would publish the address %s", addr) } } if m := keymailRe.FindString(s); m != "" { return fmt.Errorf("would publish the address %s", m) } if m := addressRe.FindString(s); m != "" { return fmt.Errorf("would publish the address %s", m) } if m := privateRe.FindString(s); m != "" { return fmt.Errorf("would link the private repo %s", m) } return nil } func capped(s []string) []string { if len(s) > maxDocs { return s[:maxDocs] } return s } var mergeSubjectRe = regexp.MustCompile(`^(Merge (branch|pull request|remote-tracking branch) |Squashed from )`) // Describe is the line a change shows: the landing commit's subject, // unless that only names the branch ("fix-x", "Merge branch 'fix-x' // into main"). Then the first line of its body that says something (a // GitHub PR title, an Amadan merge's description), and failing that the // subject of the branch's first commit. Every option is a verbatim line // from the history. func Describe(message, branch string, own []Commit) string { lines := strings.Split(message, "\n") informative := func(l string) bool { l = strings.TrimSpace(l) return l != "" && l != branch && !mergeSubjectRe.MatchString(l) && !trailerRe.MatchString(l) } if informative(lines[0]) { return strings.TrimSpace(lines[0]) } for _, l := range lines[1:] { if informative(l) { return strings.TrimSpace(l) } } for i := len(own) - 1; i >= 0; i-- { if s := subject(own[i].Message); informative(s) { return s } } return strings.TrimSpace(lines[0]) } var trailerRe = regexp.MustCompile(`^[A-Za-z-]+: .*<[^>]+>$`) func subject(msg string) string { if i := strings.IndexByte(msg, '\n'); i >= 0 { msg = msg[:i] } return strings.TrimSpace(msg) } func day(t time.Time) string { return t.UTC().Format("2006-01-02") } func short(sha string) string { if len(sha) > 8 { return sha[:8] } return sha } // semverLess orders vX.Y.Z tags numerically. func semverLess(a, b string) bool { pa, pb := semverParts(a), semverParts(b) for i := range pa { if pa[i] != pb[i] { return pa[i] < pb[i] } } return a < b } func semverParts(tag string) [3]int { var out [3]int for i, p := range strings.SplitN(strings.TrimPrefix(tag, "v"), ".", 3) { out[i], _ = strconv.Atoi(p) } return out }