Changelog fixes

This commit is contained in:
2026-05-10 18:34:16 +02:00
parent 7919ded8b4
commit 838d632a28
5 changed files with 208 additions and 37 deletions
+1 -1
View File
@@ -220,7 +220,7 @@ var commands = []command{
},
{
name: "build-changelog",
description: "Build a release changelog from tagged pull requests and write it to stdout or a file.",
description: "Build a release changelog from tagged pushes and write it to stdout or a file.",
run: runBuildChangelog,
},
}
+55 -29
View File
@@ -60,9 +60,11 @@ type generator struct {
}
type changelogEntry struct {
Title string
PullNumber string
AuthorName string
Title string
ReferenceLabel string
ReferenceURL string
ReferenceSort string
AuthorName string
}
var pullNumberPattern = regexp.MustCompile(`\(#([0-9]+)\)`)
@@ -278,7 +280,7 @@ func (g generator) renderCategory(category Category) (string, error) {
}
fmt.Fprintf(&buf, "### %s\n", section.Title)
for _, entry := range entries {
fmt.Fprintf(&buf, "- %s ([#%s](%s/pulls/%s)) - From %s\n", entry.Title, entry.PullNumber, g.repoURL, entry.PullNumber, entry.AuthorName)
fmt.Fprintf(&buf, "- %s ([%s](%s)) - From %s\n", entry.Title, entry.ReferenceLabel, entry.ReferenceURL, entry.AuthorName)
}
buf.WriteString("\n")
}
@@ -290,7 +292,7 @@ func (g generator) sectionEntries(section Section) ([]changelogEntry, error) {
return nil, nil
}
args := []string{"log", "--pretty=format:%s%x1f%an"}
args := []string{"log", "--first-parent", "--pretty=format:%H%x1f%s%x1f%an"}
if g.previousTag != "" {
args = append(args, fmt.Sprintf("%s..%s", g.previousTag, g.currentTag))
} else {
@@ -310,41 +312,57 @@ func (g generator) sectionEntries(section Section) ([]changelogEntry, error) {
if strings.TrimSpace(line) == "" {
continue
}
parts := strings.SplitN(line, "\x1f", 2)
subject := strings.TrimSpace(parts[0])
fallbackAuthor := ""
parts := strings.SplitN(line, "\x1f", 3)
commitHash := strings.TrimSpace(parts[0])
subject := ""
if len(parts) > 1 {
fallbackAuthor = strings.TrimSpace(parts[1])
subject = strings.TrimSpace(parts[1])
}
fallbackAuthor := ""
if len(parts) > 2 {
fallbackAuthor = strings.TrimSpace(parts[2])
}
matches := pullNumberPattern.FindStringSubmatch(subject)
if len(matches) != 2 {
entryKey := commitHash
if len(matches) == 2 {
entryKey = "pr:" + matches[1]
}
if _, exists := seen[entryKey]; exists {
continue
}
pullNumber := matches[1]
if _, exists := seen[pullNumber]; exists {
continue
}
seen[pullNumber] = struct{}{}
seen[entryKey] = struct{}{}
pullInfo, err := g.pullDetails(pullNumber)
if err != nil {
return nil, err
}
authorName := strings.TrimSpace(pullInfo.AuthorName)
if authorName == "" {
authorName = fallbackAuthor
}
entries = append(entries, changelogEntry{
entry := changelogEntry{
Title: sanitizeSubject(subject),
PullNumber: pullNumber,
AuthorName: authorName,
})
AuthorName: fallbackAuthor,
}
if len(matches) == 2 {
pullNumber := matches[1]
pullInfo, err := g.pullDetails(pullNumber)
if err != nil {
return nil, err
}
authorName := strings.TrimSpace(pullInfo.AuthorName)
if authorName == "" {
authorName = fallbackAuthor
}
entry.ReferenceLabel = "#" + pullNumber
entry.ReferenceURL = fmt.Sprintf("%s/pulls/%s", g.repoURL, pullNumber)
entry.ReferenceSort = "pr:" + pullNumber
entry.AuthorName = authorName
} else {
entry.ReferenceLabel = shortCommitHash(commitHash)
entry.ReferenceURL = fmt.Sprintf("%s/commit/%s", g.repoURL, commitHash)
entry.ReferenceSort = "commit:" + commitHash
}
entries = append(entries, entry)
}
sort.Slice(entries, func(i, j int) bool {
if entries[i].Title == entries[j].Title {
return entries[i].PullNumber < entries[j].PullNumber
return entries[i].ReferenceSort < entries[j].ReferenceSort
}
return entries[i].Title < entries[j].Title
})
@@ -366,6 +384,14 @@ func sanitizeSubject(subject string) string {
return title
}
func shortCommitHash(commitHash string) string {
commitHash = strings.TrimSpace(commitHash)
if len(commitHash) <= 7 {
return commitHash
}
return commitHash[:7]
}
func (g generator) pullDetails(pullNumber string) (pullInfo, error) {
if cached, ok := g.pullCache[pullNumber]; ok {
return cached, nil
+135 -1
View File
@@ -1,6 +1,16 @@
package changelog
import "testing"
import (
"bytes"
"fmt"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func TestSanitizeSubject(t *testing.T) {
t.Parallel()
@@ -25,6 +35,11 @@ func TestSanitizeSubject(t *testing.T) {
subject: `Merge pull request "Add classes overhaul (#123)"`,
want: "Add classes overhaul",
},
{
name: "direct push",
subject: "Fix direct push handling",
want: "Fix direct push handling",
},
}
for _, tt := range tests {
@@ -38,6 +53,17 @@ func TestSanitizeSubject(t *testing.T) {
}
}
func TestShortCommitHash(t *testing.T) {
t.Parallel()
if got := shortCommitHash("1234567890abcdef"); got != "1234567" {
t.Fatalf("shortCommitHash() = %q, want %q", got, "1234567")
}
if got := shortCommitHash("1234567"); got != "1234567" {
t.Fatalf("shortCommitHash() preserved %q unexpectedly as %q", "1234567", got)
}
}
func TestOwnerAndRepoFromURL(t *testing.T) {
t.Parallel()
@@ -77,3 +103,111 @@ func TestNormalizeAPIBaseURL(t *testing.T) {
}
}
}
func TestGenerateIncludesPullRequestsAndDirectPushes(t *testing.T) {
t.Parallel()
repoRoot := t.TempDir()
runGit(t, repoRoot, "init")
runGit(t, repoRoot, "config", "user.name", "Test User")
runGit(t, repoRoot, "config", "user.email", "test@example.com")
writeFile(t, filepath.Join(repoRoot, "content", "entry.txt"), "initial\n")
runGit(t, repoRoot, "add", ".")
runGit(t, repoRoot, "commit", "-m", "Initial import")
runGit(t, repoRoot, "tag", "v1.0.0")
writeFile(t, filepath.Join(repoRoot, "content", "entry.txt"), "direct\n")
runGit(t, repoRoot, "add", ".")
runGit(t, repoRoot, "commit", "-m", "Fix direct push handling")
directHash := strings.TrimSpace(runGit(t, repoRoot, "rev-parse", "HEAD"))
writeFile(t, filepath.Join(repoRoot, "content", "entry.txt"), "pull\n")
runGit(t, repoRoot, "add", ".")
runGit(t, repoRoot, "commit", "-m", "Add release summary (#12)")
runGit(t, repoRoot, "tag", "v1.1.0")
configPath := filepath.Join(repoRoot, "scripts", "changelog.json")
writeFile(t, configPath, `{
"repo_url": "REPO_URL",
"categories": [
{
"title": "Content",
"sections": [
{
"title": "Entries",
"paths": ["content/"]
}
]
}
]
}`)
var pullRequests int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/repos/org/repo/pulls/12" {
t.Fatalf("unexpected API path %q", r.URL.Path)
}
pullRequests++
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"user":{"full_name":"Patch Author","login":"patch-author"}}`)
}))
defer server.Close()
repoURL := server.URL + "/org/repo"
configData, err := os.ReadFile(configPath)
if err != nil {
t.Fatalf("read changelog config: %v", err)
}
configData = bytes.ReplaceAll(configData, []byte("REPO_URL"), []byte(repoURL))
if err := os.WriteFile(configPath, configData, 0o644); err != nil {
t.Fatalf("write changelog config: %v", err)
}
var stdout bytes.Buffer
err = Generate(Options{
RepoRoot: repoRoot,
ConfigPath: configPath,
CurrentTag: "v1.1.0",
PreviousTag: "v1.0.0",
APIBaseURL: server.URL + "/api/v1",
Stdout: &stdout,
})
if err != nil {
t.Fatalf("Generate() returned error: %v", err)
}
rendered := stdout.String()
if !strings.Contains(rendered, "- Add release summary ([#12]("+repoURL+"/pulls/12)) - From Patch Author") {
t.Fatalf("rendered changelog missing pull entry:\n%s", rendered)
}
if !strings.Contains(rendered, "- Fix direct push handling (["+shortCommitHash(directHash)+"]("+repoURL+"/commit/"+directHash+")) - From Test User") {
t.Fatalf("rendered changelog missing direct-push entry:\n%s", rendered)
}
if pullRequests != 1 {
t.Fatalf("expected 1 pull lookup, got %d", pullRequests)
}
}
func runGit(t *testing.T, repoRoot string, args ...string) string {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = repoRoot
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %s failed: %v\n%s", strings.Join(args, " "), err, output)
}
return string(output)
}
func writeFile(t *testing.T, path string, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("MkdirAll(%q): %v", path, err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("WriteFile(%q): %v", path, err)
}
}