| 1 | package changelog |
| 2 | |
| 3 | import ( |
| 4 | "reflect" |
| 5 | "strings" |
| 6 | "testing" |
| 7 | "time" |
| 8 | |
| 9 | "github.com/carlosframework/platform/internal/docsite" |
| 10 | ) |
| 11 | |
| 12 | type 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 | |
| 21 | func (g *fakeGit) Tags() ([]Tag, error) { return g.tags, nil } |
| 22 | func (g *fakeGit) FirstParent(to string, exclude []string) ([]Commit, error) { |
| 23 | return g.firstParent[strings.Join(exclude, ",")+".."+to], nil |
| 24 | } |
| 25 | func (g *fakeGit) Range(from, to string) ([]Commit, error) { return g.ranges[from+".."+to], nil } |
| 26 | func (g *fakeGit) Tombstone(b string) (string, time.Time, bool) { |
| 27 | t, ok := g.tombstones[b] |
| 28 | return t.SHA, t.Date, ok |
| 29 | } |
| 30 | func (g *fakeGit) LiveBranch(b string) bool { return g.live[b] } |
| 31 | func (g *fakeGit) DocsChanges(c Commit) ([]DocsChange, error) { |
| 32 | return g.docs[c.SHA], nil |
| 33 | } |
| 34 | |
| 35 | type fakeReleases map[string]string |
| 36 | |
| 37 | func (f fakeReleases) Body(tag string) (string, bool, error) { |
| 38 | b, ok := f[tag] |
| 39 | return b, ok, nil |
| 40 | } |
| 41 | |
| 42 | type fakeDiscussions struct { |
| 43 | byID map[int]Discussion |
| 44 | byBranch map[string][]Discussion |
| 45 | shows int |
| 46 | } |
| 47 | |
| 48 | func (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 | } |
| 56 | func (f *fakeDiscussions) ForBranch(b string) ([]Discussion, error) { return f.byBranch[b], nil } |
| 57 | |
| 58 | var ( |
| 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 | |
| 63 | const cliPage = "# The CLI\n\n## Your apps\n\n### carlos db\n\ntext\n\n### carlos services\n\ntext\n" |
| 64 | |
| 65 | func 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 | |
| 72 | func 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 | |
| 114 | func 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 | |
| 181 | func 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 | |
| 194 | func 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 | |
| 207 | func 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 | |
| 219 | func 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 | |
| 228 | func 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@example.org)", |
| 242 | "[source](https://github.com/carlosframework/platform)", |
| 243 | "[source](https://github.com/carlosframework/plat%66orm)", |
| 244 | "write to ops\\@example.org", |
| 245 | "ops&#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). |
| 263 | func 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). |
| 285 | func 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 | |
| 297 | func 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 | |
| 313 | func 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 | |
| 319 | func 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. |
| 338 | func 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 | |
| 348 | func 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 | |
| 364 | func 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 | |
| 384 | func 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 | |