Changelog fixes
This commit is contained in:
@@ -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:
|
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[]`
|
||||||
- `categories[].title`
|
- `categories[].title`
|
||||||
- `categories[].sections[]`
|
- `categories[].sections[]`
|
||||||
@@ -60,8 +60,12 @@ Paths are repo-relative path filters passed through to `git log`.
|
|||||||
|
|
||||||
## Author Contract
|
## Author Contract
|
||||||
|
|
||||||
Rendered changelog entries must resolve pull request author names from the
|
Rendered changelog entries must support both first-parent pull-request-style
|
||||||
Gitea pull request API rather than trusting the merge commit author field.
|
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:
|
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
|
This avoids detached-tag push complexity while preserving a stable artifact for
|
||||||
later aggregation.
|
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
|
## Output Contract
|
||||||
|
|
||||||
The Markdown output must remain simple and merge-friendly:
|
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
|
- comparison line naming the previous tag when one exists
|
||||||
- category headings
|
- category headings
|
||||||
- section headings
|
- section headings
|
||||||
- bullet entries in the form:
|
- bullet entries in one of these forms:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
- Title ([#123](.../pulls/123)) - From Author Name
|
- Title ([#123](.../pulls/123)) - From Author Name
|
||||||
|
- Title ([abc1234](.../commit/<sha>)) - From Author Name
|
||||||
```
|
```
|
||||||
|
|
||||||
## Deferred Work
|
## Deferred Work
|
||||||
|
|||||||
@@ -311,8 +311,10 @@ use `namespace=cid`, for example:
|
|||||||
sow-toolkit deploy-wiki --dry-run --namespace spells --category spells=42
|
sow-toolkit deploy-wiki --dry-run --namespace spells --category spells=42
|
||||||
```
|
```
|
||||||
|
|
||||||
`build-changelog` renders a release changelog from tagged pull requests. It
|
`build-changelog` renders a release changelog from tagged pushes. Pull-request
|
||||||
defaults to `scripts/changelog.json`, reads Gitea tokens from `GITEA_API_TOKEN`,
|
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
|
`GITEA_TOKEN`, or `SOW_TOOLS_TOKEN`, and writes to stdout unless `--output` is
|
||||||
provided.
|
provided.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -220,7 +220,7 @@ var commands = []command{
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "build-changelog",
|
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,
|
run: runBuildChangelog,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,7 +61,9 @@ type generator struct {
|
|||||||
|
|
||||||
type changelogEntry struct {
|
type changelogEntry struct {
|
||||||
Title string
|
Title string
|
||||||
PullNumber string
|
ReferenceLabel string
|
||||||
|
ReferenceURL string
|
||||||
|
ReferenceSort string
|
||||||
AuthorName string
|
AuthorName string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,7 +280,7 @@ func (g generator) renderCategory(category Category) (string, error) {
|
|||||||
}
|
}
|
||||||
fmt.Fprintf(&buf, "### %s\n", section.Title)
|
fmt.Fprintf(&buf, "### %s\n", section.Title)
|
||||||
for _, entry := range entries {
|
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")
|
buf.WriteString("\n")
|
||||||
}
|
}
|
||||||
@@ -290,7 +292,7 @@ func (g generator) sectionEntries(section Section) ([]changelogEntry, error) {
|
|||||||
return nil, nil
|
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 != "" {
|
if g.previousTag != "" {
|
||||||
args = append(args, fmt.Sprintf("%s..%s", g.previousTag, g.currentTag))
|
args = append(args, fmt.Sprintf("%s..%s", g.previousTag, g.currentTag))
|
||||||
} else {
|
} else {
|
||||||
@@ -310,22 +312,33 @@ func (g generator) sectionEntries(section Section) ([]changelogEntry, error) {
|
|||||||
if strings.TrimSpace(line) == "" {
|
if strings.TrimSpace(line) == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
parts := strings.SplitN(line, "\x1f", 2)
|
parts := strings.SplitN(line, "\x1f", 3)
|
||||||
subject := strings.TrimSpace(parts[0])
|
commitHash := strings.TrimSpace(parts[0])
|
||||||
fallbackAuthor := ""
|
subject := ""
|
||||||
if len(parts) > 1 {
|
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)
|
matches := pullNumberPattern.FindStringSubmatch(subject)
|
||||||
if len(matches) != 2 {
|
entryKey := commitHash
|
||||||
|
if len(matches) == 2 {
|
||||||
|
entryKey = "pr:" + matches[1]
|
||||||
|
}
|
||||||
|
if _, exists := seen[entryKey]; exists {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
pullNumber := matches[1]
|
seen[entryKey] = struct{}{}
|
||||||
if _, exists := seen[pullNumber]; exists {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[pullNumber] = struct{}{}
|
|
||||||
|
|
||||||
|
entry := changelogEntry{
|
||||||
|
Title: sanitizeSubject(subject),
|
||||||
|
AuthorName: fallbackAuthor,
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(matches) == 2 {
|
||||||
|
pullNumber := matches[1]
|
||||||
pullInfo, err := g.pullDetails(pullNumber)
|
pullInfo, err := g.pullDetails(pullNumber)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -334,17 +347,22 @@ func (g generator) sectionEntries(section Section) ([]changelogEntry, error) {
|
|||||||
if authorName == "" {
|
if authorName == "" {
|
||||||
authorName = fallbackAuthor
|
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{
|
entries = append(entries, entry)
|
||||||
Title: sanitizeSubject(subject),
|
|
||||||
PullNumber: pullNumber,
|
|
||||||
AuthorName: authorName,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sort.Slice(entries, func(i, j int) bool {
|
sort.Slice(entries, func(i, j int) bool {
|
||||||
if entries[i].Title == entries[j].Title {
|
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
|
return entries[i].Title < entries[j].Title
|
||||||
})
|
})
|
||||||
@@ -366,6 +384,14 @@ func sanitizeSubject(subject string) string {
|
|||||||
return title
|
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) {
|
func (g generator) pullDetails(pullNumber string) (pullInfo, error) {
|
||||||
if cached, ok := g.pullCache[pullNumber]; ok {
|
if cached, ok := g.pullCache[pullNumber]; ok {
|
||||||
return cached, nil
|
return cached, nil
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
package changelog
|
package changelog
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
func TestSanitizeSubject(t *testing.T) {
|
func TestSanitizeSubject(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -25,6 +35,11 @@ func TestSanitizeSubject(t *testing.T) {
|
|||||||
subject: `Merge pull request "Add classes overhaul (#123)"`,
|
subject: `Merge pull request "Add classes overhaul (#123)"`,
|
||||||
want: "Add classes overhaul",
|
want: "Add classes overhaul",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "direct push",
|
||||||
|
subject: "Fix direct push handling",
|
||||||
|
want: "Fix direct push handling",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
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) {
|
func TestOwnerAndRepoFromURL(t *testing.T) {
|
||||||
t.Parallel()
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user