diff --git a/CHANGELOG_AUTOMATION_CONTRACT.md b/CHANGELOG_AUTOMATION_CONTRACT.md index 497f2aa..e34d710 100644 --- a/CHANGELOG_AUTOMATION_CONTRACT.md +++ b/CHANGELOG_AUTOMATION_CONTRACT.md @@ -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/)) - From Author Name ``` ## Deferred Work diff --git a/README.md b/README.md index 39acd90..3ef36e1 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/internal/app/app.go b/internal/app/app.go index 81716ab..3bb1fe5 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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, }, } diff --git a/internal/changelog/changelog.go b/internal/changelog/changelog.go index 81ee71c..4df370a 100644 --- a/internal/changelog/changelog.go +++ b/internal/changelog/changelog.go @@ -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 diff --git a/internal/changelog/changelog_test.go b/internal/changelog/changelog_test.go index 4addf37..7f50ec3 100644 --- a/internal/changelog/changelog_test.go +++ b/internal/changelog/changelog_test.go @@ -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) + } +}