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 "reflect"
5 "strings"
6 "testing"
7 "time"
8
9 "github.com/carlosframework/platform/internal/docsite"
10)
11
12type fakeGit struct {
13 tags []Tag
14 firstParent map[string][]Commit // "from..to"
15 ranges map[string][]Commit
16 tombstones map[string]Tag // branch -> tip sha and date
17 live map[string]bool
18 docs map[string][]DocsChange
19}
20
21func (g *fakeGit) Tags() ([]Tag, error) { return g.tags, nil }
22func (g *fakeGit) FirstParent(to string, exclude []string) ([]Commit, error) {
23 return g.firstParent[strings.Join(exclude, ",")+".."+to], nil
24}
25func (g *fakeGit) Range(from, to string) ([]Commit, error) { return g.ranges[from+".."+to], nil }
26func (g *fakeGit) Tombstone(b string) (string, time.Time, bool) {
27 t, ok := g.tombstones[b]
28 return t.SHA, t.Date, ok
29}
30func (g *fakeGit) LiveBranch(b string) bool { return g.live[b] }
31func (g *fakeGit) DocsChanges(c Commit) ([]DocsChange, error) {
32 return g.docs[c.SHA], nil
33}
34
35type fakeReleases map[string]string
36
37func (f fakeReleases) Body(tag string) (string, bool, error) {
38 b, ok := f[tag]
39 return b, ok, nil
40}
41
42type fakeDiscussions struct {
43 byID map[int]Discussion
44 byBranch map[string][]Discussion
45 shows int
46}
47
48func (f *fakeDiscussions) Show(id int) (*Discussion, error) {
49 f.shows++
50 d, ok := f.byID[id]
51 if !ok {
52 return nil, nil
53 }
54 return &d, nil
55}
56func (f *fakeDiscussions) ForBranch(b string) ([]Discussion, error) { return f.byBranch[b], nil }
57
58var (
59 t0 = time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC)
60 t1 = time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC)
61)
62
63const cliPage = "# The CLI\n\n## Your apps\n\n### carlos db\n\ntext\n\n### carlos services\n\ntext\n"
64
65func testSite(t *testing.T) *docsite.Site {
66 t.Helper()
67 cli := docsite.Parse("cli", cliPage)
68 idx := docsite.Parse("index", "# CARLOS\n\nintro\n")
69 return &docsite.Site{Index: idx, BySlug: map[string]*docsite.Page{"cli": cli, "index": idx}}
70}
71
72func fixture() (*fakeGit, fakeReleases, *fakeDiscussions) {
73 squash := Commit{SHA: "s1", Parents: []string{"p0"}, AuthorName: "claude", AuthorEmail: "claude~paul@keymail.dev", Date: t1,
74 Message: "deploy-credential: scoped (#229)\n\nSquashed from design-deploy-credential into main."}
75 merge := Commit{SHA: "m1", Parents: []string{"s1", "b9"}, AuthorName: "GitHub", AuthorEmail: "noreply@github.com", Date: t1,
76 Message: "Merge pull request #417 from carlosframework/availability-tab\n\nissue #5"}
77 direct := Commit{SHA: "d1", Parents: []string{"m1"}, AuthorName: "Paul Campbell", AuthorEmail: "paul@tito.io", Date: t1,
78 Message: "docs: a direct fix\n\nCo-Authored-By: Claude <noreply@anthropic.com>"}
79 old := Commit{SHA: "o1", AuthorName: "Paul Campbell", AuthorEmail: "paul@tito.io", Date: t0, Message: "first (#5)"}
80 g := &fakeGit{
81 tags: []Tag{{Name: "v0.10.0", SHA: "d1", Date: t1}, {Name: "v0.9.0", SHA: "o1", Date: t0}},
82 firstParent: map[string][]Commit{
83 "..v0.9.0": {old},
84 "v0.9.0..v0.10.0": {direct, merge, squash},
85 },
86 ranges: map[string][]Commit{
87 "p0..tomb": {
88 {SHA: "c2", AuthorName: "codex", AuthorEmail: "codex~paul@keymail.dev", Date: t1, Message: "more, discussion 230"},
89 {SHA: "c1", AuthorName: "claude", AuthorEmail: "claude~paul@keymail.dev", Date: t1, Message: "start"},
90 },
91 "s1..b9": {{SHA: "b9", AuthorName: "Bill Horsman", AuthorEmail: "bill@keymail.dev", Date: t1, Message: "availability"}},
92 },
93 tombstones: map[string]Tag{"design-deploy-credential": {SHA: "tomb", Date: t1.Add(-time.Minute)}},
94 docs: map[string][]DocsChange{
95 "s1": {{Page: "cli", Body: cliPage, Added: []int{11}}},
96 "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}}},
97 },
98 }
99 rel := fakeReleases{
100 "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",
101 "v0.9.0": "First release.\n",
102 }
103 disc := &fakeDiscussions{
104 byID: map[int]Discussion{
105 229: {ID: 229, Title: "CI cannot promote", State: "closed", Created: t1.Add(-48 * time.Hour).Unix()},
106 230: {ID: 230, Title: "later follow-up", State: "open", Created: t1.Add(-time.Hour * 2).Unix()},
107 5: {ID: 5, Title: "made long after", State: "open", Created: t1.Add(24 * time.Hour).Unix()},
108 },
109 byBranch: map[string][]Discussion{"design-deploy-credential": {{ID: 231, Title: "review", State: "closed"}}},
110 }
111 return g, rel, disc
112}
113
114func TestBuild(t *testing.T) {
115 g, rel, disc := fixture()
116 links := Links{"v0.10.0": {"CI can deploy.": {"design-deploy-credential"}}}
117 c, err := Build(Sources{g, rel, disc}, testPeople, links, testSite(t))
118 if err != nil {
119 t.Fatal(err)
120 }
121 if len(c.Releases) != 2 || c.Releases[0].Tag != "v0.10.0" || c.Releases[1].Tag != "v0.9.0" {
122 t.Fatalf("releases out of order: %+v", c.Releases)
123 }
124 r := c.Releases[0]
125 if !r.Published || r.NotesURL != NotesBase+"v0.10.0" || r.Notes.Form != "bullets" {
126 t.Fatalf("notes = %+v", r)
127 }
128 bl := r.Notes.Sections[0].Bullets[0]
129 if !reflect.DeepEqual(bl.Branches, []string{"design-deploy-credential"}) || !reflect.DeepEqual(bl.Docs, []string{"/docs/cli#carlos-services"}) {
130 t.Fatalf("bullet = %+v", bl)
131 }
132 if len(r.Changes) != 3 {
133 t.Fatalf("changes = %+v", r.Changes)
134 }
135 direct, merge, squash := r.Changes[0], r.Changes[1], r.Changes[2]
136
137 if squash.Branch != "design-deploy-credential" || squash.BranchURL != RepoURL+"/-/branch/design-deploy-credential" || squash.Commits != 2 {
138 t.Errorf("squash = %+v", squash)
139 }
140 if want := []Credit{{Person: "Paul Campbell", Agents: []string{"claude", "codex"}}}; !reflect.DeepEqual(squash.Credit, want) {
141 t.Errorf("squash credit = %+v", squash.Credit)
142 }
143 var ids []int
144 for _, d := range squash.Discussions {
145 ids = append(ids, d.ID)
146 }
147 if !reflect.DeepEqual(ids, []int{229, 230, 231}) {
148 t.Errorf("squash discussions = %v", ids)
149 }
150 if !reflect.DeepEqual(squash.Docs, []string{"/docs/cli#carlos-services"}) {
151 t.Errorf("squash docs = %v", squash.Docs)
152 }
153
154 if merge.Branch != "availability-tab" || merge.BranchURL != "" || merge.Commits != 1 {
155 t.Errorf("merge = %+v", merge)
156 }
157 if want := []Credit{{Person: "Bill Horsman"}}; !reflect.DeepEqual(merge.Credit, want) {
158 t.Errorf("merge credit = %+v", merge.Credit)
159 }
160 if len(merge.Discussions) != 0 {
161 t.Errorf("a #N created after the commit was kept: %+v", merge.Discussions)
162 }
163
164 if direct.Branch != "" || direct.BranchURL != "" {
165 t.Errorf("direct = %+v", direct)
166 }
167 if want := []Credit{{Person: "Paul Campbell", Agents: []string{"claude"}}}; !reflect.DeepEqual(direct.Credit, want) {
168 t.Errorf("direct credit = %+v", direct.Credit)
169 }
170 // carlos gone is not a heading today; the index page has no
171 // heading above line 3 beyond its h1, so it links the page.
172 if !reflect.DeepEqual(direct.Docs, []string{"/docs/"}) {
173 t.Errorf("direct docs = %v", direct.Docs)
174 }
175
176 if old := c.Releases[1]; old.Notes.Form != "markdown" || len(old.Changes) != 1 || old.Changes[0].Discussions != nil {
177 t.Errorf("v0.9.0 = %+v", old)
178 }
179}
180
181func TestBuildTombstoneNewerThanSquashIsIgnored(t *testing.T) {
182 g, rel, disc := fixture()
183 g.tombstones["design-deploy-credential"] = Tag{SHA: "tomb", Date: t1.Add(72 * time.Hour)}
184 c, err := Build(Sources{g, rel, disc}, testPeople, nil, testSite(t))
185 if err != nil {
186 t.Fatal(err)
187 }
188 sq := c.Releases[0].Changes[2]
189 if sq.Commits != 1 || sq.BranchURL == "" {
190 t.Fatalf("squash = %+v", sq)
191 }
192}
193
194func TestBuildUnpublishedAndEmptyRelease(t *testing.T) {
195 g, rel, disc := fixture()
196 g.tags = append(g.tags, Tag{Name: "v0.10.1", SHA: "d1", Date: t1})
197 c, err := Build(Sources{g, rel, disc}, testPeople, nil, testSite(t))
198 if err != nil {
199 t.Fatal(err)
200 }
201 r := c.Releases[0]
202 if r.Tag != "v0.10.1" || r.Published || r.Notes != nil || r.NotesURL != "" || r.Changes == nil || len(r.Changes) != 0 {
203 t.Fatalf("v0.10.1 = %+v", r)
204 }
205}
206
207func TestBuildLinksErrors(t *testing.T) {
208 g, rel, disc := fixture()
209 for name, links := range map[string]Links{
210 "unknown lead": {"v0.10.0": {"Not a bullet.": {"design-deploy-credential"}}},
211 "foreign branch": {"v0.10.0": {"Other.": {"some-other-branch"}}},
212 } {
213 if _, err := Build(Sources{g, rel, disc}, testPeople, links, testSite(t)); err == nil || !strings.Contains(err.Error(), "changelog-links.json") {
214 t.Errorf("%s: err = %v", name, err)
215 }
216 }
217}
218
219func TestBuildUnknownPersonFails(t *testing.T) {
220 g, rel, disc := fixture()
221 g.firstParent["..v0.9.0"] = []Commit{{SHA: "o1", AuthorName: "Stranger", AuthorEmail: "who@example.com", Date: t0, Message: "x"}}
222 _, err := Build(Sources{g, rel, disc}, testPeople, nil, testSite(t))
223 if err == nil || !strings.Contains(err.Error(), "who@example.com") {
224 t.Fatalf("err = %v", err)
225 }
226}
227
228func TestCheckPublic(t *testing.T) {
229 for _, s := range []string{"live for bill@ on Tito's console", "see github.com/carlosframework/releases and /skills", "a @mention"} {
230 ok := &Changelog{Releases: []Release{{Tag: "v1.0.0", Changes: []Change{{Subject: s}}}}}
231 if err := CheckPublic(ok, testPeople); err != nil {
232 t.Errorf("%q refused: %v", s, err)
233 }
234 }
235 for _, s := range []string{
236 "thanks paul@tito.io",
237 "by claude~someone@keymail.dev",
238 "write to ops@example.org",
239 "see https://github.com/carlosframework/platform/pull/409",
240 "in github.com/carlosframework/carloku-infrastructure",
241 "[contact](mailto:ops&#64;example.org)",
242 "[source](https://github.com/carlosframework/plat&#102;orm)",
243 "[source](https://github.com/carlosframework/plat%66orm)",
244 "write to ops\\@example.org",
245 "ops&amp;#64;example.org",
246 "see **ops@example**.org now",
247 "[source](https://github.com/carlosframework/./platform)",
248 "[source](https://github.com/carlosframework/public/../platform.git)",
249 } {
250 bad := &Changelog{Releases: []Release{{Tag: "v1.0.0", Changes: []Change{{Subject: s}}}}}
251 if err := CheckPublic(bad, testPeople); err == nil {
252 t.Errorf("%q passed", s)
253 }
254 inNotes := &Changelog{Releases: []Release{{Tag: "v1.0.0", Notes: &Notes{Form: "markdown", MD: s}}}}
255 if err := CheckPublic(inNotes, testPeople); err == nil {
256 t.Errorf("%q in notes passed", s)
257 }
258 }
259}
260
261// A branch stacked on one that was squashed first must not count the
262// first branch's commits again (4d13aec3 on 99084d7a in this repo).
263func TestBuildStackedBranchSubtractsLandedCommits(t *testing.T) {
264 g, rel, disc := fixture()
265 lib := Commit{SHA: "c1", AuthorName: "claude", AuthorEmail: "claude~paul@keymail.dev", Date: t1, Message: "start"}
266 sel := Commit{SHA: "c3", AuthorName: "codex", AuthorEmail: "codex~paul@keymail.dev", Date: t1, Message: "selector"}
267 squash2 := Commit{SHA: "s2", Parents: []string{"d1"}, AuthorName: "codex", AuthorEmail: "codex~paul@keymail.dev", Date: t1,
268 Message: "genselect\n\nSquashed from feat/genselect into main."}
269 g.tags = append(g.tags, Tag{Name: "v0.11.0", SHA: "s2", Date: t1})
270 g.firstParent["v0.9.0,v0.10.0..v0.11.0"] = []Commit{squash2}
271 g.ranges["d1..tomb2"] = []Commit{sel, lib}
272 g.tombstones["feat/genselect"] = Tag{SHA: "tomb2", Date: t1}
273 c, err := Build(Sources{g, rel, disc}, testPeople, nil, testSite(t))
274 if err != nil {
275 t.Fatal(err)
276 }
277 ch := c.Releases[0].Changes[0]
278 if ch.Commits != 1 || !reflect.DeepEqual(ch.Credit, []Credit{{Person: "Paul Campbell", Agents: []string{"codex"}}}) {
279 t.Fatalf("stacked change = %+v", ch)
280 }
281}
282
283// The website anchors only levels two to four, so an addition under a
284// page's h1 links the page (v0.15.0's CLI intro, 4235e9fd).
285func TestDocsLinksSkipUnanchoredLevels(t *testing.T) {
286 g, rel, disc := fixture()
287 g.docs["d1"] = []DocsChange{{Page: "cli", Body: cliPage, Added: []int{2}}}
288 c, err := Build(Sources{g, rel, disc}, testPeople, nil, testSite(t))
289 if err != nil {
290 t.Fatal(err)
291 }
292 if got := c.Releases[0].Changes[0].Docs; !reflect.DeepEqual(got, []string{"/docs/cli"}) {
293 t.Fatalf("docs = %v", got)
294 }
295}
296
297func TestDiscussionLookupsAreCached(t *testing.T) {
298 g, rel, disc := fixture()
299 g.firstParent["..v0.9.0"] = []Commit{
300 {SHA: "o1", AuthorName: "Paul Campbell", AuthorEmail: "paul@tito.io", Date: t1, Message: "a #229"},
301 {SHA: "o2", AuthorName: "Paul Campbell", AuthorEmail: "paul@tito.io", Date: t1, Message: "b #229"},
302 }
303 if _, err := Build(Sources{g, rel, disc}, testPeople, nil, testSite(t)); err != nil {
304 t.Fatal(err)
305 }
306 // 229 once across three messages, 230 from the squash's own commit,
307 // 417 and 5 from the merge.
308 if disc.shows != 4 {
309 t.Fatalf("Show called %d times, want 4", disc.shows)
310 }
311}
312
313func TestSemverLess(t *testing.T) {
314 if !semverLess("v0.9.0", "v0.10.0") || semverLess("v0.22.2", "v0.22.1") || !semverLess("v0.24.1", "v1.0.0") {
315 t.Fatal("semver order wrong")
316 }
317}
318
319func TestRedactQuotedRecords(t *testing.T) {
320 g, rel, disc := fixture()
321 disc.byBranch["design-deploy-credential"] = []Discussion{{ID: 231, Title: "send as jelly@tito.io (two accounts)", State: "closed"}}
322 g.firstParent["v0.9.0..v0.10.0"][0].Message = "docs: thanks to ops@example.org and claude~paul@keymail.dev"
323 c, err := Build(Sources{g, rel, disc}, testPeople, nil, testSite(t))
324 if err != nil {
325 t.Fatal(err)
326 }
327 if got := c.Releases[0].Changes[0].Subject; got != "docs: thanks to [address] and [address]" {
328 t.Errorf("subject = %q", got)
329 }
330 ds := c.Releases[0].Changes[2].Discussions
331 if ds[len(ds)-1].Title != "send as [address] (two accounts)" {
332 t.Errorf("discussions = %+v", ds)
333 }
334}
335
336// v0.22.1 was tagged and never published; v0.22.2 shipped its change.
337// v0.3.0 was cut from a commit v0.2.0 (unpublished) already held.
338func TestRangeBases(t *testing.T) {
339 earlier := []Release{{Tag: "v0.1.0", Published: true}, {Tag: "v0.2.0"}, {Tag: "v0.3.0", Published: true}}
340 if got := RangeBases(true, earlier); !reflect.DeepEqual(got, []string{"v0.1.0", "v0.3.0"}) {
341 t.Errorf("published bases = %v", got)
342 }
343 if got := RangeBases(false, earlier); !reflect.DeepEqual(got, []string{"v0.1.0", "v0.2.0", "v0.3.0"}) {
344 t.Errorf("unpublished bases = %v", got)
345 }
346}
347
348func TestBuildReusesAChangeListedTwice(t *testing.T) {
349 g, rel, disc := fixture()
350 g.tags = append(g.tags, Tag{Name: "v0.11.0", SHA: "d1", Date: t1})
351 rel["v0.11.0"] = "carlos v0.11.0 (platform d1, 2026-10-05)\n\n- **Again.** x\n"
352 delete(rel, "v0.10.0")
353 g.firstParent["v0.9.0..v0.11.0"] = g.firstParent["v0.9.0..v0.10.0"]
354 c, err := Build(Sources{g, rel, disc}, testPeople, nil, testSite(t))
355 if err != nil {
356 t.Fatal(err)
357 }
358 a, b := c.Releases[0].Changes, c.Releases[1].Changes
359 if len(a) != 3 || !reflect.DeepEqual(a, b) {
360 t.Fatalf("v0.11.0 = %+v\nv0.10.0 = %+v", a, b)
361 }
362}
363
364func TestDescribe(t *testing.T) {
365 own := []Commit{{Message: "later work"}, {Message: "console: the first step\n\nbody"}}
366 cases := []struct{ msg, branch, want string }{
367 {"park: the engine (Plan 4)\n\nbody", "feat/park", "park: the engine (Plan 4)"},
368 {"Merge branch 'tenant-iso-mail' into main\n\nMail: members can't adopt (#216)\n", "tenant-iso-mail", "Mail: members can't adopt (#216)"},
369 {"Merge pull request #3 from carlosframework/depth\n\nTenant hibernation", "depth", "Tenant hibernation"},
370 {"fix/x\n\nSquashed from fix/x into main.\n\nCo-Authored-By: Claude <noreply@anthropic.com>", "fix/x", "console: the first step"},
371 {"fix/x\n\nSquashed from fix/x into main.", "fix/x", "fix/x"},
372 }
373 for i, c := range cases {
374 o := own
375 if i == len(cases)-1 {
376 o = nil
377 }
378 if got := Describe(c.msg, c.branch, o); got != c.want {
379 t.Errorf("Describe(%q) = %q, want %q", c.msg, got, c.want)
380 }
381 }
382}
383
384func TestBuildPublishedWithEmptyNotes(t *testing.T) {
385 g, rel, disc := fixture()
386 rel["v0.10.0"] = "\n"
387 c, err := Build(Sources{g, rel, disc}, testPeople, nil, testSite(t))
388 if err != nil {
389 t.Fatal(err)
390 }
391 if r := c.Releases[0]; !r.Published || r.NotesURL == "" || r.Notes != nil {
392 t.Fatalf("v0.10.0 = %+v", r)
393 }
394}
395