Files
sow-tools/internal/changelog/changelog.go
T
2026-05-10 18:34:16 +02:00

456 lines
12 KiB
Go

package changelog
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strings"
)
type Config struct {
RepoURL string `json:"repo_url"`
Categories []Category `json:"categories"`
}
type Category struct {
Title string `json:"title"`
Sections []Section `json:"sections"`
}
type Section struct {
Title string `json:"title"`
Paths []string `json:"paths"`
}
type Options struct {
RepoRoot string
ConfigPath string
OutputPath string
CurrentTag string
PreviousTag string
APIBaseURL string
Token string
Stdout io.Writer
}
type pullInfo struct {
AuthorName string
}
type generator struct {
repoRoot string
config Config
currentTag string
previousTag string
apiBaseURL string
repoURL string
owner string
name string
token string
httpClient *http.Client
pullCache map[string]pullInfo
}
type changelogEntry struct {
Title string
ReferenceLabel string
ReferenceURL string
ReferenceSort string
AuthorName string
}
var pullNumberPattern = regexp.MustCompile(`\(#([0-9]+)\)`)
func Generate(opts Options) error {
repoRoot := strings.TrimSpace(opts.RepoRoot)
if repoRoot == "" {
detectedRepoRoot, err := gitOutput("", "rev-parse", "--show-toplevel")
if err != nil {
return fmt.Errorf("detect repo root: %w", err)
}
repoRoot = strings.TrimSpace(detectedRepoRoot)
}
configPath := opts.ConfigPath
if configPath == "" {
configPath = filepath.Join(repoRoot, "scripts", "changelog.json")
}
config, err := loadConfig(configPath)
if err != nil {
return err
}
currentTag := strings.TrimSpace(opts.CurrentTag)
if currentTag == "" {
currentTag, err = gitOutput(repoRoot, "describe", "--tags", "--exact-match")
if err != nil {
return fmt.Errorf("detect current tag: %w", err)
}
currentTag = strings.TrimSpace(currentTag)
}
if currentTag == "" {
return fmt.Errorf("could not determine current tag")
}
previousTag := strings.TrimSpace(opts.PreviousTag)
if previousTag == "" {
previousTag, err = previousTagFor(repoRoot, currentTag)
if err != nil {
return err
}
}
repoURL := strings.TrimSpace(config.RepoURL)
if repoURL == "" {
return fmt.Errorf("config %s is missing repo_url", configPath)
}
apiBaseURL := strings.TrimSpace(opts.APIBaseURL)
if apiBaseURL == "" {
if apiBaseURL = strings.TrimSpace(os.Getenv("GITEA_SERVER_URL")); apiBaseURL == "" {
apiBaseURL = deriveAPIBaseURL(repoURL)
}
}
apiBaseURL = normalizeAPIBaseURL(apiBaseURL)
if apiBaseURL == "" {
return fmt.Errorf("could not determine Gitea API base URL")
}
owner, name, err := ownerAndRepoFromURL(repoURL)
if err != nil {
return err
}
g := generator{
repoRoot: repoRoot,
config: config,
currentTag: currentTag,
previousTag: previousTag,
apiBaseURL: strings.TrimRight(apiBaseURL, "/"),
repoURL: strings.TrimRight(repoURL, "/"),
owner: owner,
name: name,
token: firstNonEmpty(opts.Token, os.Getenv("GITEA_API_TOKEN"), os.Getenv("GITEA_TOKEN"), os.Getenv("SOW_TOOLS_TOKEN")),
httpClient: http.DefaultClient,
pullCache: map[string]pullInfo{},
}
rendered, err := g.render()
if err != nil {
return err
}
if opts.OutputPath != "" {
if err := os.MkdirAll(filepath.Dir(opts.OutputPath), 0o755); err != nil {
return fmt.Errorf("create output directory: %w", err)
}
if err := os.WriteFile(opts.OutputPath, rendered, 0o644); err != nil {
return fmt.Errorf("write changelog: %w", err)
}
return nil
}
stdout := opts.Stdout
if stdout == nil {
stdout = os.Stdout
}
_, err = stdout.Write(rendered)
return err
}
func loadConfig(path string) (Config, error) {
var config Config
data, err := os.ReadFile(path)
if err != nil {
return Config{}, fmt.Errorf("read changelog config %s: %w", path, err)
}
if err := json.Unmarshal(data, &config); err != nil {
return Config{}, fmt.Errorf("parse changelog config %s: %w", path, err)
}
if len(config.Categories) == 0 {
return Config{}, fmt.Errorf("changelog config %s does not define any categories", path)
}
return config, nil
}
func previousTagFor(repoRoot string, currentTag string) (string, error) {
output, err := gitOutput(repoRoot, "for-each-ref", "--sort=-creatordate", "--format=%(refname:strip=2)", "refs/tags")
if err != nil {
return "", fmt.Errorf("list tags: %w", err)
}
tags := strings.Fields(output)
for idx, tag := range tags {
if tag != currentTag {
continue
}
if idx+1 < len(tags) {
return tags[idx+1], nil
}
return "", nil
}
return "", fmt.Errorf("current tag %s was not found in repository tag list", currentTag)
}
func deriveAPIBaseURL(repoURL string) string {
parsed, err := url.Parse(repoURL)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return ""
}
return fmt.Sprintf("%s://%s/api/v1", parsed.Scheme, parsed.Host)
}
func normalizeAPIBaseURL(raw string) string {
raw = strings.TrimRight(strings.TrimSpace(raw), "/")
if raw == "" {
return ""
}
if strings.HasSuffix(raw, "/api/v1") {
return raw
}
return raw + "/api/v1"
}
func ownerAndRepoFromURL(repoURL string) (string, string, error) {
parsed, err := url.Parse(repoURL)
if err != nil {
return "", "", fmt.Errorf("parse repo_url %s: %w", repoURL, err)
}
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
if len(parts) < 2 {
return "", "", fmt.Errorf("repo_url %s does not include owner/repo", repoURL)
}
owner := strings.TrimSpace(parts[0])
name := strings.TrimSuffix(strings.TrimSpace(parts[1]), ".git")
if owner == "" || name == "" {
return "", "", fmt.Errorf("repo_url %s does not include owner/repo", repoURL)
}
return owner, name, nil
}
func (g generator) render() ([]byte, error) {
publishDate, err := gitOutput(g.repoRoot, "log", "-1", "--format=%cs", g.currentTag)
if err != nil {
return nil, fmt.Errorf("resolve publish date: %w", err)
}
publishDate = strings.TrimSpace(publishDate)
var buf bytes.Buffer
fmt.Fprintf(&buf, "# %s - %s\n\n", g.currentTag, publishDate)
if g.previousTag != "" {
fmt.Fprintf(&buf, "_Compared against %s_\n\n", g.previousTag)
} else {
buf.WriteString("_Initial tagged release_\n\n")
}
for _, category := range g.config.Categories {
categoryText, err := g.renderCategory(category)
if err != nil {
return nil, err
}
if categoryText == "" {
continue
}
fmt.Fprintf(&buf, "## %s\n\n", category.Title)
buf.WriteString(categoryText)
}
return buf.Bytes(), nil
}
func (g generator) renderCategory(category Category) (string, error) {
var buf bytes.Buffer
for _, section := range category.Sections {
entries, err := g.sectionEntries(section)
if err != nil {
return "", err
}
if len(entries) == 0 {
continue
}
fmt.Fprintf(&buf, "### %s\n", section.Title)
for _, entry := range entries {
fmt.Fprintf(&buf, "- %s ([%s](%s)) - From %s\n", entry.Title, entry.ReferenceLabel, entry.ReferenceURL, entry.AuthorName)
}
buf.WriteString("\n")
}
return buf.String(), nil
}
func (g generator) sectionEntries(section Section) ([]changelogEntry, error) {
if len(section.Paths) == 0 {
return nil, nil
}
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 {
args = append(args, g.currentTag)
}
args = append(args, "--")
args = append(args, section.Paths...)
output, err := gitOutput(g.repoRoot, args...)
if err != nil {
return nil, fmt.Errorf("list git log for section %s: %w", section.Title, err)
}
seen := map[string]struct{}{}
var entries []changelogEntry
for _, line := range strings.Split(output, "\n") {
if strings.TrimSpace(line) == "" {
continue
}
parts := strings.SplitN(line, "\x1f", 3)
commitHash := strings.TrimSpace(parts[0])
subject := ""
if len(parts) > 1 {
subject = strings.TrimSpace(parts[1])
}
fallbackAuthor := ""
if len(parts) > 2 {
fallbackAuthor = strings.TrimSpace(parts[2])
}
matches := pullNumberPattern.FindStringSubmatch(subject)
entryKey := commitHash
if len(matches) == 2 {
entryKey = "pr:" + matches[1]
}
if _, exists := seen[entryKey]; exists {
continue
}
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
}
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].ReferenceSort < entries[j].ReferenceSort
}
return entries[i].Title < entries[j].Title
})
return entries, nil
}
func sanitizeSubject(subject string) string {
title := strings.TrimSpace(subject)
title = pullNumberPattern.ReplaceAllString(title, "")
title = strings.TrimSpace(title)
title = strings.TrimPrefix(title, "Merge pull request")
title = strings.TrimSpace(title)
title = strings.Trim(title, `"'`)
title = strings.TrimSpace(title)
if title == "" {
return strings.TrimSpace(subject)
}
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
}
endpoint := fmt.Sprintf("%s/repos/%s/%s/pulls/%s", g.apiBaseURL, url.PathEscape(g.owner), url.PathEscape(g.name), url.PathEscape(pullNumber))
req, err := http.NewRequest(http.MethodGet, endpoint, nil)
if err != nil {
return pullInfo{}, fmt.Errorf("build Gitea request for pull #%s: %w", pullNumber, err)
}
req.Header.Set("Accept", "application/json")
if g.token != "" {
req.Header.Set("Authorization", "token "+g.token)
}
resp, err := g.httpClient.Do(req)
if err != nil {
return pullInfo{}, fmt.Errorf("request Gitea pull #%s: %w", pullNumber, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return pullInfo{}, fmt.Errorf("request Gitea pull #%s: unexpected HTTP %d: %s", pullNumber, resp.StatusCode, strings.TrimSpace(string(body)))
}
var payload struct {
User struct {
FullName string `json:"full_name"`
Login string `json:"login"`
} `json:"user"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
return pullInfo{}, fmt.Errorf("decode Gitea pull #%s: %w", pullNumber, err)
}
info := pullInfo{AuthorName: firstNonEmpty(strings.TrimSpace(payload.User.FullName), strings.TrimSpace(payload.User.Login))}
g.pullCache[pullNumber] = info
return info, nil
}
func gitOutput(repoRoot string, args ...string) (string, error) {
cmd := exec.Command("git", args...)
if repoRoot != "" {
cmd.Dir = repoRoot
}
output, err := cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("%s: %s", strings.Join(cmd.Args, " "), strings.TrimSpace(string(output)))
}
return string(output), nil
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}