`)
+ var divCount int
+ var lastChunk int
+ for i, chunk := range chunks {
+ chunks[i] = strings.TrimSpace(chunks[i])
+ divsInChunk := strings.Count(chunk, `
0 {
+ newText = newText[:m2[0]]
+ }
+ return "* " + newText
+}
+
+func splitBreakingLines(allLines []string) (breaking, nonBreaking []string) {
+ for _, pr := range allLines {
+ if strings.Contains(pr, "!: ") {
+ breaking = append(breaking, pr)
+ } else {
+ nonBreaking = append(nonBreaking, pr)
+ }
+ }
+ return breaking, nonBreaking
+}
+
+func genRefLines(breaking, nonBreaking []string) (ref, refNon []string) {
+ for _, pr := range breaking {
+ m := descriptionRE.FindStringSubmatch(pr)
+ if len(m) == 3 {
+ ref = append(ref, strings.Replace(pr, m[1], m[2], 1))
+ }
+ }
+ for _, pr := range nonBreaking {
+ m := descriptionRE.FindStringSubmatch(pr)
+ if len(m) == 3 {
+ refNon = append(refNon, strings.Replace(pr, m[1], m[2], 1))
+ }
+ }
+ return ref, refNon
+}
+
+func newChangesSinceRelease(priorRelease string) string {
+ url := fmt.Sprintf("%v/compare/%v...master", baseWebURL, priorRelease)
+ resp, err := http.Get(url) //nolint: gosec
+ must(err)
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(resp.Body)
+ must(err)
+
+ return string(body)
+}
+
+func getPriorRelease() string {
+ resp, err := http.Get(baseWebURL)
+ must(err)
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(resp.Body)
+ must(err)
+
+ matches := releaseRE.FindStringSubmatch(string(body))
+ if len(matches) != 2 {
+ log.Fatal("could not find release info")
+ }
+
+ priorRelease := strings.TrimSpace(matches[1])
+ if priorRelease == "" {
+ log.Fatal("found empty prior release version")
+ }
+
+ return priorRelease
+}
+
+func must(err error) {
+ if err != nil {
+ log.Fatal(err)
+ }
+}
+
+const releaseNotesFmt = `This release contains the following breaking API changes:
+
+%v
+
+...and the following additional changes:
+
+%v
+
+&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&
+
+This release contains the following breaking API changes:
+
+%v
+
+...and the following additional changes:
+
+%v
+`
diff --git a/tools/gen-release-notes/main_test.go b/tools/gen-release-notes/main_test.go
new file mode 100644
index 00000000000..878d1be501e
--- /dev/null
+++ b/tools/gen-release-notes/main_test.go
@@ -0,0 +1,235 @@
+// Copyright 2025 The go-github AUTHORS. All rights reserved.
+//
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package main
+
+import (
+ _ "embed"
+ "strings"
+ "testing"
+
+ "github.com/google/go-cmp/cmp"
+)
+
+//go:embed testdata/compare-v76.html
+var compareV76HTML string
+
+//go:embed testdata/release-notes-v77.txt
+var want string
+
+func TestGenReleaseNotes(t *testing.T) {
+ t.Parallel()
+ got := genReleaseNotes(compareV76HTML)
+
+ if diff := cmp.Diff(want, got); diff != "" {
+ t.Log(got)
+ t.Errorf("genReleaseNotes mismatch (-want +got):\n%v", diff)
+ }
+}
+
+func TestSplitIntoPRs(t *testing.T) {
+ t.Parallel()
+
+ text := compareV76HTML[191600:]
+ got := splitIntoPRs(text)
+ want := []string{
+ `* Bump go-github from v75 to v76 in /scrape (#3783)`,
+ `* Add custom jsonfieldname linter to ensure Go field name matches JSON tag name (#3757)`,
+ `* chore: Fix typo in comment (#3786)`,
+ `* feat: Add support for private registries endpoints (#3785)`,
+ "* Only set `Authorization` when `token` is available (#3789)",
+ `* test: Ensure Authorization is not set with empty token (#3790)`,
+ `* Fix spelling issues (#3792)`,
+ "* refactor!: Remove pointer from required field of CreateStatus API (#3794)\n BREAKING CHANGE: `RepositoriesService.CreateStatus` now takes value for `status`, not pointer.",
+ `* Add test cases for JSON resource marshaling - SCIM (#3798)`,
+ `* fix: Org/Enterprise UpdateRepositoryRulesetClearBypassActor sends empty array (#3796)`,
+ `* feat!: Add support for project items CRUD and project fields read operations (#3793)`,
+ }
+
+ if len(got) != len(want) {
+ t.Log(strings.Join(got, "\n"))
+ t.Fatalf("splitIntoPRs = %v lines, want %v", len(got), len(want))
+ }
+ for i := range got {
+ if got[i] != want[i] {
+ t.Errorf("splitIntoPRs[%v] =\n%v\n, want \n%v", i, got[i], want[i])
+ }
+ }
+}
+
+func TestMatchDivs(t *testing.T) {
+ t.Parallel()
+
+ text := `
+
+
+ You signed in with another tab or window. Reload to refresh your session.
+ You signed out in another tab or window. Reload to refresh your session.
+ You switched accounts on another tab or window. Reload to refresh your session.
+
+ Dismiss alert
+
+
+
+
+
+Browse the repository at this point in the history
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Loading
+
+
+
+
+
+
This comparison is taking too long to generate.
+
Unfortunately it looks like we can’t render this comparison for you right now. It might be too big, or there might be something weird with your repository.
+
+
+ You can try running this command locally to see the comparison on your machine:
+ git diff v76.0.0...master
+