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 "encoding/json"
5 "fmt"
6 "html"
7 "net/url"
8 "path"
9 "regexp"
10 "slices"
11 "sort"
12 "strconv"
13 "strings"
14 "time"
15
16 "github.com/carlosframework/platform/internal/docsite"
17)
18
19// RepoURL is the public Amadan repo every branch, commit and discussion
20// link points into.
21const RepoURL = "https://amadan.net/carlos/platform"
22
23// NotesBase is where each published release's notes live.
24const NotesBase = "https://github.com/carlosframework/releases/releases/tag/"
25
26type Tag struct {
27 Name string
28 SHA string
29 Date time.Time
30}
31
32type Commit struct {
33 SHA string
34 Parents []string
35 AuthorName, AuthorEmail string
36 Date time.Time // committer date
37 Message string
38}
39
40// DocsChange is one docs/site page a commit added lines to: the page's
41// source as of the commit, and the 1-based new-file lines added.
42type DocsChange struct {
43 Page string // slug, e.g. "cli"
44 Body string
45 Added []int
46}
47
48type Git interface {
49 Tags() ([]Tag, error)
50 // FirstParent lists the commits on to's first-parent line that no
51 // excluded tag reaches, newest first.
52 FirstParent(to string, exclude []string) ([]Commit, error)
53 // Range lists the non-merge commits in from..to, newest first.
54 Range(from, to string) ([]Commit, error)
55 // Tombstone is the last tip Amadan kept for a merged branch, at
56 // refs/amadan/closed/<branch>.
57 Tombstone(branch string) (string, time.Time, bool)
58 // LiveBranch reports whether origin still carries the branch.
59 LiveBranch(branch string) bool
60 DocsChanges(c Commit) ([]DocsChange, error)
61}
62
63type Releases interface {
64 // Body is a tag's published release notes; ok is false when the tag
65 // was never published.
66 Body(tag string) (body string, ok bool, err error)
67}
68
69type Discussion struct {
70 ID int `json:"id"`
71 Title string `json:"title"`
72 State string `json:"state"`
73 Created int64 `json:"-"`
74}
75
76type Discussions interface {
77 // Show returns nil, nil when there is no such discussion.
78 Show(id int) (*Discussion, error)
79 ForBranch(branch string) ([]Discussion, error)
80}
81
82type Sources struct {
83 Git Git
84 Releases Releases
85 Discussions Discussions
86}
87
88// Links is changelog-links.json: tag, then a bullet's bold lead, then the
89// branches behind that bullet.
90type Links map[string]map[string][]string
91
92type Changelog struct {
93 Note string `json:"//"`
94 Repo string `json:"repo"`
95 Releases []Release `json:"releases"`
96}
97
98type Release struct {
99 Tag string `json:"tag"`
100 Date string `json:"date"`
101 SHA string `json:"sha"`
102 Published bool `json:"published"`
103 NotesURL string `json:"notes_url,omitempty"`
104 Notes *Notes `json:"notes"`
105 Changes []Change `json:"changes"`
106}
107
108type Change struct {
109 SHA string `json:"sha"`
110 Date string `json:"date"`
111 Subject string `json:"subject"`
112 Branch string `json:"branch,omitempty"`
113 BranchURL string `json:"branch_url,omitempty"`
114 Commits int `json:"commits"`
115 Discussions []Discussion `json:"discussions,omitempty"`
116 Docs []string `json:"docs,omitempty"`
117 Credit []Credit `json:"credit"`
118}
119
120const generatedNote = "Generated by go run ./cmd/changelog from git, the published release notes and Amadan. Do not edit; regenerate."
121
122// maxDocs caps the docs links one change or bullet shows.
123const maxDocs = 5
124
125// refSlack allows for clock skew between the machine that committed and
126// the hub that stamped a discussion's creation.
127const refSlack = time.Hour
128
129// Build joins every source into the changelog, newest release first.
130func Build(src Sources, people People, links Links, site *docsite.Site) (*Changelog, error) {
131 tags, err := src.Git.Tags()
132 if err != nil {
133 return nil, err
134 }
135 sort.Slice(tags, func(i, j int) bool { return semverLess(tags[i].Name, tags[j].Name) })
136
137 b := &builder{src: src, people: people, site: site, discussions: map[int]*Discussion{},
138 landed: map[string]bool{}, built: map[string]*Change{}}
139 b.cliHeadings = map[string]string{}
140 if cli := site.BySlug["cli"]; cli != nil {
141 for _, h := range cli.Headings {
142 if anchored(h) {
143 b.cliHeadings[h.Text] = h.Anchor
144 }
145 }
146 }
147
148 out := &Changelog{Note: generatedNote, Repo: RepoURL}
149 used := map[string]map[string]bool{}
150 for _, t := range tags {
151 r, err := b.release(t, out.Releases)
152 if err != nil {
153 return nil, fmt.Errorf("%s: %w", t.Name, err)
154 }
155 used[t.Name] = applyLinks(r, links[t.Name])
156 out.Releases = append(out.Releases, *r)
157 }
158 for tag, byLead := range links {
159 for lead, branches := range byLead {
160 if !used[tag][lead] {
161 return nil, fmt.Errorf("changelog-links.json: %s has no bullet whose lead is %q", tag, lead)
162 }
163 if err := checkLinkBranches(out, tag, branches); err != nil {
164 return nil, err
165 }
166 }
167 }
168 for i, j := 0, len(out.Releases)-1; i < j; i, j = i+1, j-1 {
169 out.Releases[i], out.Releases[j] = out.Releases[j], out.Releases[i]
170 }
171 if err := CheckPublic(out, people); err != nil {
172 return nil, err
173 }
174 return out, nil
175}
176
177type builder struct {
178 src Sources
179 people People
180 site *docsite.Site
181 cliHeadings map[string]string
182 discussions map[int]*Discussion
183 // landed is every commit an earlier change already accounted for,
184 // so a branch stacked on one squashed before it does not count the
185 // earlier branch's commits again.
186 landed map[string]bool
187 // built is every change already joined, by its landing commit: an
188 // unpublished tag and the published release after it can list the
189 // same commit.
190 built map[string]*Change
191}
192
193// RangeBases is the tags a release's changes are counted from. A
194// published release lists what no earlier published release carried, so
195// it owns the work of an unpublished tag before it (v0.22.2 shipped
196// v0.22.1's change); an unpublished tag lists what no earlier tag of any
197// kind carried. earlier is in semver order.
198func RangeBases(published bool, earlier []Release) []string {
199 var out []string
200 for _, r := range earlier {
201 if r.Published || !published {
202 out = append(out, r.Tag)
203 }
204 }
205 return out
206}
207
208func (b *builder) release(t Tag, earlier []Release) (*Release, error) {
209 r := &Release{Tag: t.Name, Date: day(t.Date), SHA: t.SHA, Changes: []Change{}}
210 body, ok, err := b.src.Releases.Body(t.Name)
211 if err != nil {
212 return nil, err
213 }
214 if ok {
215 r.Published = true
216 r.NotesURL = NotesBase + t.Name
217 // A release published with an empty body (v0.18.0) has a notes
218 // page to link and nothing to show here.
219 if n := ParseNotes(body); n.MD != "" || len(n.Sections) > 0 {
220 r.Notes = n
221 }
222 for si := range notesSections(r.Notes) {
223 for bi := range r.Notes.Sections[si].Bullets {
224 bl := &r.Notes.Sections[si].Bullets[bi]
225 bl.Docs = capped(CLIDocLinks(strings.Join(append([]string{bl.MD}, bl.Items...), "\n"), b.cliHeadings))
226 }
227 }
228 }
229 commits, err := b.src.Git.FirstParent(t.Name, RangeBases(r.Published, earlier))
230 if err != nil {
231 return nil, err
232 }
233 // Oldest first, so the landed set is complete for each change; the
234 // page lists them newest first.
235 for i := len(commits) - 1; i >= 0; i-- {
236 ch, err := b.change(commits[i])
237 if err != nil {
238 return nil, fmt.Errorf("%s: %w", short(commits[i].SHA), err)
239 }
240 r.Changes = append(r.Changes, *ch)
241 }
242 for i, j := 0, len(r.Changes)-1; i < j; i, j = i+1, j-1 {
243 r.Changes[i], r.Changes[j] = r.Changes[j], r.Changes[i]
244 }
245 return r, nil
246}
247
248func notesSections(n *Notes) []NoteSection {
249 if n == nil {
250 return nil
251 }
252 return n.Sections
253}
254
255func (b *builder) change(c Commit) (*Change, error) {
256 if ch, ok := b.built[c.SHA]; ok {
257 return ch, nil
258 }
259 ch := &Change{
260 SHA: c.SHA,
261 Date: day(c.Date),
262 Branch: BranchName(c.Message),
263 }
264 b.built[c.SHA] = ch
265 own, onAmadan, err := b.ownCommits(c, ch.Branch)
266 if err != nil {
267 return nil, err
268 }
269 ch.Subject = Redact(Describe(c.Message, ch.Branch, own))
270 b.landed[c.SHA] = true
271 for _, o := range own {
272 b.landed[o.SHA] = true
273 }
274 if onAmadan {
275 ch.BranchURL = RepoURL + "/-/branch/" + url.PathEscape(ch.Branch)
276 }
277 ch.Commits = len(own)
278
279 idents := make([]CommitIdent, 0, len(own))
280 for _, o := range own {
281 idents = append(idents, CommitIdent{o.AuthorName, o.AuthorEmail, o.Message})
282 }
283 ch.Credit, err = Tally(idents, b.people)
284 if err != nil {
285 return nil, err
286 }
287 if len(ch.Credit) == 0 {
288 // Every own commit was a skipped identity (GitHub's merge
289 // button); the landing commit is the evidence left.
290 ch.Credit, err = Tally([]CommitIdent{{c.AuthorName, c.AuthorEmail, c.Message}}, b.people)
291 if err != nil {
292 return nil, err
293 }
294 }
295
296 ch.Discussions, err = b.discussionsFor(c, own, ch.Branch, onAmadan)
297 if err != nil {
298 return nil, err
299 }
300
301 docs, err := b.src.Git.DocsChanges(c)
302 if err != nil {
303 return nil, err
304 }
305 ch.Docs = capped(b.docsLinks(docs))
306 return ch, nil
307}
308
309// ownCommits is the work a first-parent commit landed. onAmadan reports
310// whether the branch has a page on Amadan to link to.
311func (b *builder) ownCommits(c Commit, branch string) ([]Commit, bool, error) {
312 onAmadan := false
313 if branch != "" {
314 onAmadan = b.src.Git.LiveBranch(branch) || strings.Contains(c.Message, "Squashed from "+branch+" into")
315 }
316 if len(c.Parents) == 2 {
317 own, err := b.src.Git.Range(c.Parents[0], c.Parents[1])
318 if err != nil {
319 return nil, false, err
320 }
321 own = b.unlanded(own)
322 if branch != "" {
323 if _, _, ok := b.src.Git.Tombstone(branch); ok {
324 onAmadan = true
325 }
326 }
327 return own, onAmadan, nil
328 }
329 if branch != "" && len(c.Parents) == 1 {
330 if tip, when, ok := b.src.Git.Tombstone(branch); ok {
331 onAmadan = true
332 // A tombstone newer than the squash belongs to a later
333 // branch that reused the name; its commits are not this
334 // change's.
335 if !when.After(c.Date.Add(refSlack)) {
336 own, err := b.src.Git.Range(c.Parents[0], tip)
337 if err != nil {
338 return nil, false, err
339 }
340 if own = b.unlanded(own); len(own) > 0 {
341 return own, onAmadan, nil
342 }
343 }
344 }
345 }
346 return []Commit{c}, onAmadan, nil
347}
348
349func (b *builder) unlanded(cs []Commit) []Commit {
350 out := cs[:0:0]
351 for _, c := range cs {
352 if !b.landed[c.SHA] {
353 out = append(out, c)
354 }
355 }
356 return out
357}
358
359// anchored reports whether the website gives a heading an id: its
360// markdown-it-anchor configuration covers levels two to four.
361func anchored(h docsite.Heading) bool { return h.Level >= 2 && h.Level <= 4 }
362
363func (b *builder) discussionsFor(c Commit, own []Commit, branch string, onAmadan bool) ([]Discussion, error) {
364 found := map[int]Discussion{}
365 consider := func(msg string, at time.Time) error {
366 for _, id := range DiscussionRefs(msg) {
367 if _, ok := found[id]; ok {
368 continue
369 }
370 d, err := b.show(id)
371 if err != nil {
372 return err
373 }
374 if d != nil && time.Unix(d.Created, 0).Before(at.Add(refSlack)) {
375 found[id] = *d
376 }
377 }
378 return nil
379 }
380 if err := consider(c.Message, c.Date); err != nil {
381 return nil, err
382 }
383 for _, o := range own {
384 if o.SHA == c.SHA {
385 continue
386 }
387 if err := consider(o.Message, o.Date); err != nil {
388 return nil, err
389 }
390 }
391 if onAmadan {
392 list, err := b.src.Discussions.ForBranch(branch)
393 if err != nil {
394 return nil, err
395 }
396 for _, d := range list {
397 d.Title = Redact(d.Title)
398 found[d.ID] = d
399 }
400 }
401 if len(found) == 0 {
402 return nil, nil
403 }
404 out := make([]Discussion, 0, len(found))
405 for _, d := range found {
406 out = append(out, d)
407 }
408 sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
409 return out, nil
410}
411
412func (b *builder) show(id int) (*Discussion, error) {
413 if d, ok := b.discussions[id]; ok {
414 return d, nil
415 }
416 d, err := b.src.Discussions.Show(id)
417 if err != nil {
418 return nil, err
419 }
420 if d != nil {
421 d.Title = Redact(d.Title)
422 }
423 b.discussions[id] = d
424 return d, nil
425}
426
427// docsLinks maps each added line to the nearest heading above it (level
428// two or deeper) in the page as the commit left it, and keeps the link
429// only if today's docs still have that page and heading.
430func (b *builder) docsLinks(changes []DocsChange) []string {
431 var out []string
432 seen := map[string]bool{}
433 add := func(l string) {
434 if !seen[l] {
435 seen[l] = true
436 out = append(out, l)
437 }
438 }
439 for _, dc := range changes {
440 today := b.site.BySlug[dc.Page]
441 if today == nil {
442 continue
443 }
444 anchors := map[string]bool{}
445 for _, h := range today.Headings {
446 if anchored(h) {
447 anchors[h.Anchor] = true
448 }
449 }
450 then := docsite.Parse(dc.Page, dc.Body)
451 for _, line := range dc.Added {
452 anchor := ""
453 for _, h := range then.Headings {
454 if h.Line > line {
455 break
456 }
457 switch {
458 case anchored(h):
459 anchor = h.Anchor
460 case h.Level == 1:
461 anchor = ""
462 }
463 }
464 if anchor != "" && anchors[anchor] {
465 add(pageURL(dc.Page) + "#" + anchor)
466 } else if anchor == "" {
467 add(pageURL(dc.Page))
468 }
469 }
470 }
471 // A page link says less than a section link on the same page.
472 kept := out[:0:0]
473 for _, l := range out {
474 if !strings.Contains(l, "#") && slices.ContainsFunc(out, func(o string) bool { return strings.HasPrefix(o, l+"#") }) {
475 continue
476 }
477 kept = append(kept, l)
478 }
479 return kept
480}
481
482func pageURL(slug string) string {
483 if slug == "index" {
484 return "/docs/"
485 }
486 return "/docs/" + slug
487}
488
489// applyLinks sets each bullet's branches from the hand-kept map and
490// reports which leads it used.
491func applyLinks(r *Release, byLead map[string][]string) map[string]bool {
492 used := map[string]bool{}
493 if r.Notes == nil {
494 return used
495 }
496 for si := range r.Notes.Sections {
497 for bi := range r.Notes.Sections[si].Bullets {
498 bl := &r.Notes.Sections[si].Bullets[bi]
499 if br, ok := byLead[bl.Lead]; ok && bl.Lead != "" {
500 bl.Branches = br
501 used[bl.Lead] = true
502 }
503 }
504 }
505 return used
506}
507
508func checkLinkBranches(c *Changelog, tag string, branches []string) error {
509 for _, r := range c.Releases {
510 if r.Tag != tag {
511 continue
512 }
513 have := map[string]bool{}
514 for _, ch := range r.Changes {
515 if ch.Branch != "" {
516 have[ch.Branch] = true
517 }
518 }
519 for _, br := range branches {
520 if !have[br] {
521 return fmt.Errorf("changelog-links.json: %s links %q, which did not land in that release", tag, br)
522 }
523 }
524 }
525 return nil
526}
527
528var (
529 addressRe = regexp.MustCompile(`(?i)[a-z0-9._%+~-]+@[a-z0-9-]+(\.[a-z0-9-]+)*\.[a-z]{2,}`)
530 keymailRe = regexp.MustCompile(`(?i)[a-z0-9._-]+~[a-z0-9._-]+@[a-z0-9.-]+`)
531 // privateRe is every link into a repo a visitor cannot open: the
532 // platform's GitHub mirror is private, and so are the five source
533 // products' repos. The public ones (releases, homebrew-tap, skills,
534 // website) are allowed.
535 privateRe = regexp.MustCompile(`(?i)github\.com/carlosframework/(platform|platform-infrastructure|carloku[a-z-]*)\b`)
536)
537
538var privateURLRe = regexp.MustCompile(`(?i)(https?://)?github\.com/carlosframework/(platform|platform-infrastructure|carloku[a-z-]*)\b\S*`)
539
540// Redact replaces every address in a record the page quotes verbatim (a
541// commit subject, a discussion title) with "[address]". Amadan shows the
542// original; the changelog does not repeat it.
543//
544// A link into a private repo becomes "[private repo]" the same way.
545func Redact(s string) string {
546 s = keymailRe.ReplaceAllString(s, "[address]")
547 s = addressRe.ReplaceAllString(s, "[address]")
548 return privateURLRe.ReplaceAllString(s, "[private repo]")
549}
550
551// CheckPublic fails if the changelog would publish something a public
552// page must not: an email address of any kind (every address people.json
553// names, anything shaped like one, an agent account), or a link into a
554// private repo. The generator runs it before writing and the gate runs
555// it over the committed file.
556func CheckPublic(c *Changelog, people People) error {
557 for _, r := range c.Releases {
558 if r.Notes != nil {
559 if err := checkPublicValue(r.Notes, people); err != nil {
560 return fmt.Errorf("%s notes: %w", r.Tag, err)
561 }
562 }
563 for _, ch := range r.Changes {
564 if err := checkPublicValue(ch, people); err != nil {
565 return fmt.Errorf("%s change %s (%s): %w", r.Tag, short(ch.SHA), ch.Subject, err)
566 }
567 }
568 }
569 return checkPublicValue(c, people)
570}
571
572func checkPublicValue(v any, people People) error {
573 raw, err := json.Marshal(v)
574 if err != nil {
575 return err
576 }
577 var tree any
578 if err := json.Unmarshal(raw, &tree); err != nil {
579 return err
580 }
581 var texts []string
582 collectStrings(tree, &texts)
583 for _, t := range texts {
584 for _, form := range decodedForms(t) {
585 if err := checkPublicText(strings.ToLower(form), people); err != nil {
586 return err
587 }
588 }
589 }
590 return nil
591}
592
593func collectStrings(v any, out *[]string) {
594 switch x := v.(type) {
595 case string:
596 *out = append(*out, x)
597 case []any:
598 for _, e := range x {
599 collectStrings(e, out)
600 }
601 case map[string]any:
602 for _, e := range x {
603 collectStrings(e, out)
604 }
605 }
606}
607
608var (
609 mdEscapeRe = regexp.MustCompile(`\\([!-/:-@\[-` + "`" + `{-~])`)
610 percentRe = regexp.MustCompile(`%[0-9a-fA-F]{2}`)
611)
612
613// decodedForms is a string as stored and as a reader would see it once
614// markdown and the browser have decoded it: HTML entities, markdown
615// backslash escapes and percent-encoding undone, each alone and all
616// together. "ops&#64;example.org" is an address on the page.
617func decodedForms(s string) []string {
618 ent := html.UnescapeString(s)
619 md := mdEscapeRe.ReplaceAllString(s, "$1")
620 pct := percentRe.ReplaceAllStringFunc(s, func(m string) string {
621 if b, err := strconv.ParseUint(m[1:], 16, 8); err == nil {
622 return string(rune(b))
623 }
624 return m
625 })
626 all := percentRe.ReplaceAllStringFunc(mdEscapeRe.ReplaceAllString(html.UnescapeString(html.UnescapeString(s)), "$1"), func(m string) string {
627 if b, err := strconv.ParseUint(m[1:], 16, 8); err == nil {
628 return string(rune(b))
629 }
630 return m
631 })
632 // What a browser makes of markdown emphasis and dot segments:
633 // "**ops@example**.org" reads as an address, and
634 // "carlosframework/./platform" resolves to the private repo.
635 plain := githubPathRe.ReplaceAllStringFunc(emphasisRe.ReplaceAllString(all, ""), func(m string) string {
636 return "github.com" + path.Clean(m[len("github.com"):])
637 })
638 return []string{s, ent, md, pct, all, plain}
639}
640
641var (
642 emphasisRe = regexp.MustCompile("[*_`~]+")
643 githubPathRe = regexp.MustCompile(`(?i)github\.com/[^\s)"'<>\]]*`)
644)
645
646func checkPublicText(s string, people People) error {
647 for addr := range people {
648 if strings.Contains(addr, "@") && strings.Contains(s, strings.ToLower(addr)) {
649 return fmt.Errorf("would publish the address %s", addr)
650 }
651 }
652 if m := keymailRe.FindString(s); m != "" {
653 return fmt.Errorf("would publish the address %s", m)
654 }
655 if m := addressRe.FindString(s); m != "" {
656 return fmt.Errorf("would publish the address %s", m)
657 }
658 if m := privateRe.FindString(s); m != "" {
659 return fmt.Errorf("would link the private repo %s", m)
660 }
661 return nil
662}
663
664func capped(s []string) []string {
665 if len(s) > maxDocs {
666 return s[:maxDocs]
667 }
668 return s
669}
670
671var mergeSubjectRe = regexp.MustCompile(`^(Merge (branch|pull request|remote-tracking branch) |Squashed from )`)
672
673// Describe is the line a change shows: the landing commit's subject,
674// unless that only names the branch ("fix-x", "Merge branch 'fix-x'
675// into main"). Then the first line of its body that says something (a
676// GitHub PR title, an Amadan merge's description), and failing that the
677// subject of the branch's first commit. Every option is a verbatim line
678// from the history.
679func Describe(message, branch string, own []Commit) string {
680 lines := strings.Split(message, "\n")
681 informative := func(l string) bool {
682 l = strings.TrimSpace(l)
683 return l != "" && l != branch && !mergeSubjectRe.MatchString(l) && !trailerRe.MatchString(l)
684 }
685 if informative(lines[0]) {
686 return strings.TrimSpace(lines[0])
687 }
688 for _, l := range lines[1:] {
689 if informative(l) {
690 return strings.TrimSpace(l)
691 }
692 }
693 for i := len(own) - 1; i >= 0; i-- {
694 if s := subject(own[i].Message); informative(s) {
695 return s
696 }
697 }
698 return strings.TrimSpace(lines[0])
699}
700
701var trailerRe = regexp.MustCompile(`^[A-Za-z-]+: .*<[^>]+>$`)
702
703func subject(msg string) string {
704 if i := strings.IndexByte(msg, '\n'); i >= 0 {
705 msg = msg[:i]
706 }
707 return strings.TrimSpace(msg)
708}
709
710func day(t time.Time) string { return t.UTC().Format("2006-01-02") }
711
712func short(sha string) string {
713 if len(sha) > 8 {
714 return sha[:8]
715 }
716 return sha
717}
718
719// semverLess orders vX.Y.Z tags numerically.
720func semverLess(a, b string) bool {
721 pa, pb := semverParts(a), semverParts(b)
722 for i := range pa {
723 if pa[i] != pb[i] {
724 return pa[i] < pb[i]
725 }
726 }
727 return a < b
728}
729
730func semverParts(tag string) [3]int {
731 var out [3]int
732 for i, p := range strings.SplitN(strings.TrimPrefix(tag, "v"), ".", 3) {
733 out[i], _ = strconv.Atoi(p)
734 }
735 return out
736}
737