package changelog import ( "reflect" "strings" "testing" "time" "github.com/carlosframework/platform/internal/docsite" ) type fakeGit struct { tags []Tag firstParent map[string][]Commit // "from..to" ranges map[string][]Commit tombstones map[string]Tag // branch -> tip sha and date live map[string]bool docs map[string][]DocsChange } func (g *fakeGit) Tags() ([]Tag, error) { return g.tags, nil } func (g *fakeGit) FirstParent(to string, exclude []string) ([]Commit, error) { return g.firstParent[strings.Join(exclude, ",")+".."+to], nil } func (g *fakeGit) Range(from, to string) ([]Commit, error) { return g.ranges[from+".."+to], nil } func (g *fakeGit) Tombstone(b string) (string, time.Time, bool) { t, ok := g.tombstones[b] return t.SHA, t.Date, ok } func (g *fakeGit) LiveBranch(b string) bool { return g.live[b] } func (g *fakeGit) DocsChanges(c Commit) ([]DocsChange, error) { return g.docs[c.SHA], nil } type fakeReleases map[string]string func (f fakeReleases) Body(tag string) (string, bool, error) { b, ok := f[tag] return b, ok, nil } type fakeDiscussions struct { byID map[int]Discussion byBranch map[string][]Discussion shows int } func (f *fakeDiscussions) Show(id int) (*Discussion, error) { f.shows++ d, ok := f.byID[id] if !ok { return nil, nil } return &d, nil } func (f *fakeDiscussions) ForBranch(b string) ([]Discussion, error) { return f.byBranch[b], nil } var ( t0 = time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC) t1 = time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC) ) const cliPage = "# The CLI\n\n## Your apps\n\n### carlos db\n\ntext\n\n### carlos services\n\ntext\n" func testSite(t *testing.T) *docsite.Site { t.Helper() cli := docsite.Parse("cli", cliPage) idx := docsite.Parse("index", "# CARLOS\n\nintro\n") return &docsite.Site{Index: idx, BySlug: map[string]*docsite.Page{"cli": cli, "index": idx}} } func fixture() (*fakeGit, fakeReleases, *fakeDiscussions) { squash := Commit{SHA: "s1", Parents: []string{"p0"}, AuthorName: "claude", AuthorEmail: "claude~paul@keymail.dev", Date: t1, Message: "deploy-credential: scoped (#229)\n\nSquashed from design-deploy-credential into main."} merge := Commit{SHA: "m1", Parents: []string{"s1", "b9"}, AuthorName: "GitHub", AuthorEmail: "noreply@github.com", Date: t1, Message: "Merge pull request #417 from carlosframework/availability-tab\n\nissue #5"} direct := Commit{SHA: "d1", Parents: []string{"m1"}, AuthorName: "Paul Campbell", AuthorEmail: "paul@tito.io", Date: t1, Message: "docs: a direct fix\n\nCo-Authored-By: Claude "} old := Commit{SHA: "o1", AuthorName: "Paul Campbell", AuthorEmail: "paul@tito.io", Date: t0, Message: "first (#5)"} g := &fakeGit{ tags: []Tag{{Name: "v0.10.0", SHA: "d1", Date: t1}, {Name: "v0.9.0", SHA: "o1", Date: t0}}, firstParent: map[string][]Commit{ "..v0.9.0": {old}, "v0.9.0..v0.10.0": {direct, merge, squash}, }, ranges: map[string][]Commit{ "p0..tomb": { {SHA: "c2", AuthorName: "codex", AuthorEmail: "codex~paul@keymail.dev", Date: t1, Message: "more, discussion 230"}, {SHA: "c1", AuthorName: "claude", AuthorEmail: "claude~paul@keymail.dev", Date: t1, Message: "start"}, }, "s1..b9": {{SHA: "b9", AuthorName: "Bill Horsman", AuthorEmail: "bill@keymail.dev", Date: t1, Message: "availability"}}, }, tombstones: map[string]Tag{"design-deploy-credential": {SHA: "tomb", Date: t1.Add(-time.Minute)}}, docs: map[string][]DocsChange{ "s1": {{Page: "cli", Body: cliPage, Added: []int{11}}}, "d1": {{Page: "cli", Body: "# The CLI\n\n### carlos gone\n\nx\n", Added: []int{5}}, {Page: "index", Body: "# CARLOS\n\nintro\n", Added: []int{3}}}, }, } rel := fakeReleases{ "v0.10.0": "carlos v0.10.0 (platform d1, 2026-10-05)\n\n- **CI can deploy.** `carlos services create --role deploy`.\n- **Other.** text\n", "v0.9.0": "First release.\n", } disc := &fakeDiscussions{ byID: map[int]Discussion{ 229: {ID: 229, Title: "CI cannot promote", State: "closed", Created: t1.Add(-48 * time.Hour).Unix()}, 230: {ID: 230, Title: "later follow-up", State: "open", Created: t1.Add(-time.Hour * 2).Unix()}, 5: {ID: 5, Title: "made long after", State: "open", Created: t1.Add(24 * time.Hour).Unix()}, }, byBranch: map[string][]Discussion{"design-deploy-credential": {{ID: 231, Title: "review", State: "closed"}}}, } return g, rel, disc } func TestBuild(t *testing.T) { g, rel, disc := fixture() links := Links{"v0.10.0": {"CI can deploy.": {"design-deploy-credential"}}} c, err := Build(Sources{g, rel, disc}, testPeople, links, testSite(t)) if err != nil { t.Fatal(err) } if len(c.Releases) != 2 || c.Releases[0].Tag != "v0.10.0" || c.Releases[1].Tag != "v0.9.0" { t.Fatalf("releases out of order: %+v", c.Releases) } r := c.Releases[0] if !r.Published || r.NotesURL != NotesBase+"v0.10.0" || r.Notes.Form != "bullets" { t.Fatalf("notes = %+v", r) } bl := r.Notes.Sections[0].Bullets[0] if !reflect.DeepEqual(bl.Branches, []string{"design-deploy-credential"}) || !reflect.DeepEqual(bl.Docs, []string{"/docs/cli#carlos-services"}) { t.Fatalf("bullet = %+v", bl) } if len(r.Changes) != 3 { t.Fatalf("changes = %+v", r.Changes) } direct, merge, squash := r.Changes[0], r.Changes[1], r.Changes[2] if squash.Branch != "design-deploy-credential" || squash.BranchURL != RepoURL+"/-/branch/design-deploy-credential" || squash.Commits != 2 { t.Errorf("squash = %+v", squash) } if want := []Credit{{Person: "Paul Campbell", Agents: []string{"claude", "codex"}}}; !reflect.DeepEqual(squash.Credit, want) { t.Errorf("squash credit = %+v", squash.Credit) } var ids []int for _, d := range squash.Discussions { ids = append(ids, d.ID) } if !reflect.DeepEqual(ids, []int{229, 230, 231}) { t.Errorf("squash discussions = %v", ids) } if !reflect.DeepEqual(squash.Docs, []string{"/docs/cli#carlos-services"}) { t.Errorf("squash docs = %v", squash.Docs) } if merge.Branch != "availability-tab" || merge.BranchURL != "" || merge.Commits != 1 { t.Errorf("merge = %+v", merge) } if want := []Credit{{Person: "Bill Horsman"}}; !reflect.DeepEqual(merge.Credit, want) { t.Errorf("merge credit = %+v", merge.Credit) } if len(merge.Discussions) != 0 { t.Errorf("a #N created after the commit was kept: %+v", merge.Discussions) } if direct.Branch != "" || direct.BranchURL != "" { t.Errorf("direct = %+v", direct) } if want := []Credit{{Person: "Paul Campbell", Agents: []string{"claude"}}}; !reflect.DeepEqual(direct.Credit, want) { t.Errorf("direct credit = %+v", direct.Credit) } // carlos gone is not a heading today; the index page has no // heading above line 3 beyond its h1, so it links the page. if !reflect.DeepEqual(direct.Docs, []string{"/docs/"}) { t.Errorf("direct docs = %v", direct.Docs) } if old := c.Releases[1]; old.Notes.Form != "markdown" || len(old.Changes) != 1 || old.Changes[0].Discussions != nil { t.Errorf("v0.9.0 = %+v", old) } } func TestBuildTombstoneNewerThanSquashIsIgnored(t *testing.T) { g, rel, disc := fixture() g.tombstones["design-deploy-credential"] = Tag{SHA: "tomb", Date: t1.Add(72 * time.Hour)} c, err := Build(Sources{g, rel, disc}, testPeople, nil, testSite(t)) if err != nil { t.Fatal(err) } sq := c.Releases[0].Changes[2] if sq.Commits != 1 || sq.BranchURL == "" { t.Fatalf("squash = %+v", sq) } } func TestBuildUnpublishedAndEmptyRelease(t *testing.T) { g, rel, disc := fixture() g.tags = append(g.tags, Tag{Name: "v0.10.1", SHA: "d1", Date: t1}) c, err := Build(Sources{g, rel, disc}, testPeople, nil, testSite(t)) if err != nil { t.Fatal(err) } r := c.Releases[0] if r.Tag != "v0.10.1" || r.Published || r.Notes != nil || r.NotesURL != "" || r.Changes == nil || len(r.Changes) != 0 { t.Fatalf("v0.10.1 = %+v", r) } } func TestBuildLinksErrors(t *testing.T) { g, rel, disc := fixture() for name, links := range map[string]Links{ "unknown lead": {"v0.10.0": {"Not a bullet.": {"design-deploy-credential"}}}, "foreign branch": {"v0.10.0": {"Other.": {"some-other-branch"}}}, } { if _, err := Build(Sources{g, rel, disc}, testPeople, links, testSite(t)); err == nil || !strings.Contains(err.Error(), "changelog-links.json") { t.Errorf("%s: err = %v", name, err) } } } func TestBuildUnknownPersonFails(t *testing.T) { g, rel, disc := fixture() g.firstParent["..v0.9.0"] = []Commit{{SHA: "o1", AuthorName: "Stranger", AuthorEmail: "who@example.com", Date: t0, Message: "x"}} _, err := Build(Sources{g, rel, disc}, testPeople, nil, testSite(t)) if err == nil || !strings.Contains(err.Error(), "who@example.com") { t.Fatalf("err = %v", err) } } func TestCheckPublic(t *testing.T) { for _, s := range []string{"live for bill@ on Tito's console", "see github.com/carlosframework/releases and /skills", "a @mention"} { ok := &Changelog{Releases: []Release{{Tag: "v1.0.0", Changes: []Change{{Subject: s}}}}} if err := CheckPublic(ok, testPeople); err != nil { t.Errorf("%q refused: %v", s, err) } } for _, s := range []string{ "thanks paul@tito.io", "by claude~someone@keymail.dev", "write to ops@example.org", "see https://github.com/carlosframework/platform/pull/409", "in github.com/carlosframework/carloku-infrastructure", "[contact](mailto:ops@example.org)", "[source](https://github.com/carlosframework/platform)", "[source](https://github.com/carlosframework/plat%66orm)", "write to ops\\@example.org", "ops@example.org", "see **ops@example**.org now", "[source](https://github.com/carlosframework/./platform)", "[source](https://github.com/carlosframework/public/../platform.git)", } { bad := &Changelog{Releases: []Release{{Tag: "v1.0.0", Changes: []Change{{Subject: s}}}}} if err := CheckPublic(bad, testPeople); err == nil { t.Errorf("%q passed", s) } inNotes := &Changelog{Releases: []Release{{Tag: "v1.0.0", Notes: &Notes{Form: "markdown", MD: s}}}} if err := CheckPublic(inNotes, testPeople); err == nil { t.Errorf("%q in notes passed", s) } } } // A branch stacked on one that was squashed first must not count the // first branch's commits again (4d13aec3 on 99084d7a in this repo). func TestBuildStackedBranchSubtractsLandedCommits(t *testing.T) { g, rel, disc := fixture() lib := Commit{SHA: "c1", AuthorName: "claude", AuthorEmail: "claude~paul@keymail.dev", Date: t1, Message: "start"} sel := Commit{SHA: "c3", AuthorName: "codex", AuthorEmail: "codex~paul@keymail.dev", Date: t1, Message: "selector"} squash2 := Commit{SHA: "s2", Parents: []string{"d1"}, AuthorName: "codex", AuthorEmail: "codex~paul@keymail.dev", Date: t1, Message: "genselect\n\nSquashed from feat/genselect into main."} g.tags = append(g.tags, Tag{Name: "v0.11.0", SHA: "s2", Date: t1}) g.firstParent["v0.9.0,v0.10.0..v0.11.0"] = []Commit{squash2} g.ranges["d1..tomb2"] = []Commit{sel, lib} g.tombstones["feat/genselect"] = Tag{SHA: "tomb2", Date: t1} c, err := Build(Sources{g, rel, disc}, testPeople, nil, testSite(t)) if err != nil { t.Fatal(err) } ch := c.Releases[0].Changes[0] if ch.Commits != 1 || !reflect.DeepEqual(ch.Credit, []Credit{{Person: "Paul Campbell", Agents: []string{"codex"}}}) { t.Fatalf("stacked change = %+v", ch) } } // The website anchors only levels two to four, so an addition under a // page's h1 links the page (v0.15.0's CLI intro, 4235e9fd). func TestDocsLinksSkipUnanchoredLevels(t *testing.T) { g, rel, disc := fixture() g.docs["d1"] = []DocsChange{{Page: "cli", Body: cliPage, Added: []int{2}}} c, err := Build(Sources{g, rel, disc}, testPeople, nil, testSite(t)) if err != nil { t.Fatal(err) } if got := c.Releases[0].Changes[0].Docs; !reflect.DeepEqual(got, []string{"/docs/cli"}) { t.Fatalf("docs = %v", got) } } func TestDiscussionLookupsAreCached(t *testing.T) { g, rel, disc := fixture() g.firstParent["..v0.9.0"] = []Commit{ {SHA: "o1", AuthorName: "Paul Campbell", AuthorEmail: "paul@tito.io", Date: t1, Message: "a #229"}, {SHA: "o2", AuthorName: "Paul Campbell", AuthorEmail: "paul@tito.io", Date: t1, Message: "b #229"}, } if _, err := Build(Sources{g, rel, disc}, testPeople, nil, testSite(t)); err != nil { t.Fatal(err) } // 229 once across three messages, 230 from the squash's own commit, // 417 and 5 from the merge. if disc.shows != 4 { t.Fatalf("Show called %d times, want 4", disc.shows) } } func TestSemverLess(t *testing.T) { if !semverLess("v0.9.0", "v0.10.0") || semverLess("v0.22.2", "v0.22.1") || !semverLess("v0.24.1", "v1.0.0") { t.Fatal("semver order wrong") } } func TestRedactQuotedRecords(t *testing.T) { g, rel, disc := fixture() disc.byBranch["design-deploy-credential"] = []Discussion{{ID: 231, Title: "send as jelly@tito.io (two accounts)", State: "closed"}} g.firstParent["v0.9.0..v0.10.0"][0].Message = "docs: thanks to ops@example.org and claude~paul@keymail.dev" c, err := Build(Sources{g, rel, disc}, testPeople, nil, testSite(t)) if err != nil { t.Fatal(err) } if got := c.Releases[0].Changes[0].Subject; got != "docs: thanks to [address] and [address]" { t.Errorf("subject = %q", got) } ds := c.Releases[0].Changes[2].Discussions if ds[len(ds)-1].Title != "send as [address] (two accounts)" { t.Errorf("discussions = %+v", ds) } } // v0.22.1 was tagged and never published; v0.22.2 shipped its change. // v0.3.0 was cut from a commit v0.2.0 (unpublished) already held. func TestRangeBases(t *testing.T) { earlier := []Release{{Tag: "v0.1.0", Published: true}, {Tag: "v0.2.0"}, {Tag: "v0.3.0", Published: true}} if got := RangeBases(true, earlier); !reflect.DeepEqual(got, []string{"v0.1.0", "v0.3.0"}) { t.Errorf("published bases = %v", got) } if got := RangeBases(false, earlier); !reflect.DeepEqual(got, []string{"v0.1.0", "v0.2.0", "v0.3.0"}) { t.Errorf("unpublished bases = %v", got) } } func TestBuildReusesAChangeListedTwice(t *testing.T) { g, rel, disc := fixture() g.tags = append(g.tags, Tag{Name: "v0.11.0", SHA: "d1", Date: t1}) rel["v0.11.0"] = "carlos v0.11.0 (platform d1, 2026-10-05)\n\n- **Again.** x\n" delete(rel, "v0.10.0") g.firstParent["v0.9.0..v0.11.0"] = g.firstParent["v0.9.0..v0.10.0"] c, err := Build(Sources{g, rel, disc}, testPeople, nil, testSite(t)) if err != nil { t.Fatal(err) } a, b := c.Releases[0].Changes, c.Releases[1].Changes if len(a) != 3 || !reflect.DeepEqual(a, b) { t.Fatalf("v0.11.0 = %+v\nv0.10.0 = %+v", a, b) } } func TestDescribe(t *testing.T) { own := []Commit{{Message: "later work"}, {Message: "console: the first step\n\nbody"}} cases := []struct{ msg, branch, want string }{ {"park: the engine (Plan 4)\n\nbody", "feat/park", "park: the engine (Plan 4)"}, {"Merge branch 'tenant-iso-mail' into main\n\nMail: members can't adopt (#216)\n", "tenant-iso-mail", "Mail: members can't adopt (#216)"}, {"Merge pull request #3 from carlosframework/depth\n\nTenant hibernation", "depth", "Tenant hibernation"}, {"fix/x\n\nSquashed from fix/x into main.\n\nCo-Authored-By: Claude ", "fix/x", "console: the first step"}, {"fix/x\n\nSquashed from fix/x into main.", "fix/x", "fix/x"}, } for i, c := range cases { o := own if i == len(cases)-1 { o = nil } if got := Describe(c.msg, c.branch, o); got != c.want { t.Errorf("Describe(%q) = %q, want %q", c.msg, got, c.want) } } } func TestBuildPublishedWithEmptyNotes(t *testing.T) { g, rel, disc := fixture() rel["v0.10.0"] = "\n" c, err := Build(Sources{g, rel, disc}, testPeople, nil, testSite(t)) if err != nil { t.Fatal(err) } if r := c.Releases[0]; !r.Published || r.NotesURL == "" || r.Notes != nil { t.Fatalf("v0.10.0 = %+v", r) } }