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
+13 -4
View File
@@ -49,7 +49,7 @@ If `--current-tag` is omitted, the generator must resolve the exact tag at
Each consumer repo provides `scripts/changelog.json` with:
- `repo_url`: canonical web URL for pull request links
- `repo_url`: canonical web URL for pull request and commit links
- `categories[]`
- `categories[].title`
- `categories[].sections[]`
@@ -60,8 +60,12 @@ Paths are repo-relative path filters passed through to `git log`.
## Author Contract
Rendered changelog entries must resolve pull request author names from the
Gitea pull request API rather than trusting the merge commit author field.
Rendered changelog entries must support both first-parent pull-request-style
pushes and direct first-parent pushes between the selected tags.
For pull-request-style commits that include a `(#123)` suffix, the generator
must resolve author names from the Gitea pull request API rather than trusting
the merge commit author field.
Expected endpoint shape:
@@ -82,6 +86,10 @@ The changelog is a staged build artifact, not a committed source-controlled file
This avoids detached-tag push complexity while preserving a stable artifact for
later aggregation.
For direct pushes without a pull number suffix, the generator must render the
commit using its commit hash link and commit author without calling the pull
request API.
## Output Contract
The Markdown output must remain simple and merge-friendly:
@@ -90,10 +98,11 @@ The Markdown output must remain simple and merge-friendly:
- comparison line naming the previous tag when one exists
- category headings
- section headings
- bullet entries in the form:
- bullet entries in one of these forms:
```text
- Title ([#123](.../pulls/123)) - From Author Name
- Title ([abc1234](.../commit/<sha>)) - From Author Name
```
## Deferred Work
+4 -2
View File
@@ -311,8 +311,10 @@ use `namespace=cid`, for example:
sow-toolkit deploy-wiki --dry-run --namespace spells --category spells=42
```
`build-changelog` renders a release changelog from tagged pull requests. It
defaults to `scripts/changelog.json`, reads Gitea tokens from `GITEA_API_TOKEN`,
`build-changelog` renders a release changelog from tagged pushes. Pull-request
style commits keep their PR links and authors from the Gitea API; direct pushes
are rendered as commit links using the commit author. It defaults to
`scripts/changelog.json`, reads Gitea tokens from `GITEA_API_TOKEN`,
`GITEA_TOKEN`, or `SOW_TOOLS_TOKEN`, and writes to stdout unless `--output` is
provided.
+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,
},
}
+45 -19
View File
@@ -61,7 +61,9 @@ type generator struct {
type changelogEntry struct {
Title string
PullNumber string
ReferenceLabel string
ReferenceURL string
ReferenceSort string
AuthorName string
}
@@ -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,22 +312,33 @@ 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{}{}
entry := changelogEntry{
Title: sanitizeSubject(subject),
AuthorName: fallbackAuthor,
}
if len(matches) == 2 {
pullNumber := matches[1]
pullInfo, err := g.pullDetails(pullNumber)
if err != nil {
return nil, err
@@ -334,17 +347,22 @@ func (g generator) sectionEntries(section Section) ([]changelogEntry, error) {
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, changelogEntry{
Title: sanitizeSubject(subject),
PullNumber: pullNumber,
AuthorName: authorName,
})
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)
}
}