Changelog Automation
This commit is contained in:
@@ -0,0 +1,104 @@
|
|||||||
|
# Changelog Automation Contract
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This contract governs release changelog generation across:
|
||||||
|
|
||||||
|
- `toolkit/` as the implementation owner
|
||||||
|
- `module/` as a changelog-producing consumer
|
||||||
|
- `assets/` as a changelog-producing consumer
|
||||||
|
|
||||||
|
The immediate goal is to produce stable per-repo `CHANGELOG.md` release artifacts
|
||||||
|
that can later be merged by a NodeBB bot into one combined release post.
|
||||||
|
|
||||||
|
## Ownership Split
|
||||||
|
|
||||||
|
- `toolkit/`
|
||||||
|
- owns the shared changelog generator implementation
|
||||||
|
- owns the CLI contract exposed through `sow-toolkit`
|
||||||
|
- owns the config schema used by consumer repos
|
||||||
|
- `module/`
|
||||||
|
- owns its changelog section mapping under `scripts/changelog.json`
|
||||||
|
- owns invoking changelog generation during its tag release flow
|
||||||
|
- `assets/`
|
||||||
|
- owns its changelog section mapping under `scripts/changelog.json`
|
||||||
|
- owns invoking changelog generation during its tag release flow
|
||||||
|
|
||||||
|
## Shared CLI Contract
|
||||||
|
|
||||||
|
The canonical generator entrypoint is:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sow-toolkit build-changelog
|
||||||
|
```
|
||||||
|
|
||||||
|
Supported flags:
|
||||||
|
|
||||||
|
- `--config <path>`: repo-local JSON section config
|
||||||
|
- `--output <path>`: write rendered Markdown to a file; otherwise stdout
|
||||||
|
- `--current-tag <tag>`: optional explicit current tag
|
||||||
|
- `--previous-tag <tag>`: optional explicit previous tag
|
||||||
|
- `--api-base-url <url>`: optional explicit Gitea API base URL
|
||||||
|
- `--token <token>`: optional explicit Gitea API token
|
||||||
|
|
||||||
|
If `--current-tag` is omitted, the generator must resolve the exact tag at
|
||||||
|
`HEAD`. If `--previous-tag` is omitted, it must select the next older tag by
|
||||||
|
`creatordate`.
|
||||||
|
|
||||||
|
## Config Contract
|
||||||
|
|
||||||
|
Each consumer repo provides `scripts/changelog.json` with:
|
||||||
|
|
||||||
|
- `repo_url`: canonical web URL for pull request links
|
||||||
|
- `categories[]`
|
||||||
|
- `categories[].title`
|
||||||
|
- `categories[].sections[]`
|
||||||
|
- `categories[].sections[].title`
|
||||||
|
- `categories[].sections[].paths[]`
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Expected endpoint shape:
|
||||||
|
|
||||||
|
- `GET /api/v1/repos/{owner}/{repo}/pulls/{index}`
|
||||||
|
- prefer `user.full_name`
|
||||||
|
- fall back to `user.login` only if `full_name` is empty
|
||||||
|
|
||||||
|
## Release Contract
|
||||||
|
|
||||||
|
For each version-tagged release workflow:
|
||||||
|
|
||||||
|
1. generate `build/CHANGELOG.md` for the current tag;
|
||||||
|
2. upload `CHANGELOG.md` as a release asset on the versioned release;
|
||||||
|
3. keep the file name stable across repos so later automation can fetch it
|
||||||
|
without repo-specific asset naming logic.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Output Contract
|
||||||
|
|
||||||
|
The Markdown output must remain simple and merge-friendly:
|
||||||
|
|
||||||
|
- title line with current tag and publish date
|
||||||
|
- comparison line naming the previous tag when one exists
|
||||||
|
- category headings
|
||||||
|
- section headings
|
||||||
|
- bullet entries in the form:
|
||||||
|
|
||||||
|
```text
|
||||||
|
- Title ([#123](.../pulls/123)) - From Author Name
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deferred Work
|
||||||
|
|
||||||
|
- add a dedicated bot that fetches `CHANGELOG.md` assets from module and assets
|
||||||
|
releases, merges them, and publishes a single NodeBB release post
|
||||||
|
- decide whether changelog generation should later move from shell wrappers into
|
||||||
|
first-class repo-root user commands
|
||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/changelog"
|
||||||
"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/pipeline"
|
"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/pipeline"
|
||||||
"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project"
|
"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project"
|
||||||
"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/topdata"
|
"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/topdata"
|
||||||
@@ -158,6 +159,11 @@ var commands = []command{
|
|||||||
description: "Deploy generated wiki pages to NodeBB.",
|
description: "Deploy generated wiki pages to NodeBB.",
|
||||||
run: runDeployWiki,
|
run: runDeployWiki,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "build-changelog",
|
||||||
|
description: "Build a release changelog from tagged pull requests and write it to stdout or a file.",
|
||||||
|
run: runBuildChangelog,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
func Run(args []string) (int, error) {
|
func Run(args []string) (int, error) {
|
||||||
@@ -830,6 +836,86 @@ func parseDeployWikiArgs(commandName string, args []string) (topdata.DeployWikiO
|
|||||||
return opts, nil
|
return opts, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type buildChangelogOptions struct {
|
||||||
|
configPath string
|
||||||
|
outputPath string
|
||||||
|
currentTag string
|
||||||
|
previousTag string
|
||||||
|
apiBaseURL string
|
||||||
|
token string
|
||||||
|
}
|
||||||
|
|
||||||
|
func runBuildChangelog(ctx context) error {
|
||||||
|
opts, err := parseBuildChangelogArgs("build-changelog", ctx.args[1:])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return changelog.Generate(changelog.Options{
|
||||||
|
RepoRoot: ctx.cwd,
|
||||||
|
ConfigPath: opts.configPath,
|
||||||
|
OutputPath: opts.outputPath,
|
||||||
|
CurrentTag: opts.currentTag,
|
||||||
|
PreviousTag: opts.previousTag,
|
||||||
|
APIBaseURL: opts.apiBaseURL,
|
||||||
|
Token: opts.token,
|
||||||
|
Stdout: ctx.stdout,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseBuildChangelogArgs(commandName string, args []string) (buildChangelogOptions, error) {
|
||||||
|
opts := buildChangelogOptions{}
|
||||||
|
usage := fmt.Errorf("usage: %s [--config <path>] [--output <path>] [--current-tag <tag>] [--previous-tag <tag>] [--api-base-url <url>] [--token <token>]", commandName)
|
||||||
|
|
||||||
|
for i := 0; i < len(args); i++ {
|
||||||
|
arg := args[i]
|
||||||
|
switch arg {
|
||||||
|
case "--config":
|
||||||
|
i++
|
||||||
|
if i >= len(args) || strings.TrimSpace(args[i]) == "" {
|
||||||
|
return opts, usage
|
||||||
|
}
|
||||||
|
opts.configPath = args[i]
|
||||||
|
case "--output":
|
||||||
|
i++
|
||||||
|
if i >= len(args) || strings.TrimSpace(args[i]) == "" {
|
||||||
|
return opts, usage
|
||||||
|
}
|
||||||
|
opts.outputPath = args[i]
|
||||||
|
case "--current-tag":
|
||||||
|
i++
|
||||||
|
if i >= len(args) || strings.TrimSpace(args[i]) == "" {
|
||||||
|
return opts, usage
|
||||||
|
}
|
||||||
|
opts.currentTag = args[i]
|
||||||
|
case "--previous-tag":
|
||||||
|
i++
|
||||||
|
if i >= len(args) || strings.TrimSpace(args[i]) == "" {
|
||||||
|
return opts, usage
|
||||||
|
}
|
||||||
|
opts.previousTag = args[i]
|
||||||
|
case "--api-base-url":
|
||||||
|
i++
|
||||||
|
if i >= len(args) || strings.TrimSpace(args[i]) == "" {
|
||||||
|
return opts, usage
|
||||||
|
}
|
||||||
|
opts.apiBaseURL = args[i]
|
||||||
|
case "--token":
|
||||||
|
i++
|
||||||
|
if i >= len(args) || strings.TrimSpace(args[i]) == "" {
|
||||||
|
return opts, usage
|
||||||
|
}
|
||||||
|
opts.token = args[i]
|
||||||
|
case "-h", "--help":
|
||||||
|
return opts, usage
|
||||||
|
default:
|
||||||
|
return opts, fmt.Errorf("unknown %s argument %q", commandName, arg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return opts, nil
|
||||||
|
}
|
||||||
|
|
||||||
func parseWikiCategoryList(raw string, target map[string]int) error {
|
func parseWikiCategoryList(raw string, target map[string]int) error {
|
||||||
for _, part := range strings.Split(raw, ",") {
|
for _, part := range strings.Split(raw, ",") {
|
||||||
part = strings.TrimSpace(part)
|
part = strings.TrimSpace(part)
|
||||||
|
|||||||
@@ -0,0 +1,429 @@
|
|||||||
|
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
|
||||||
|
PullNumber 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/pulls/%s)) - From %s\n", entry.Title, entry.PullNumber, g.repoURL, entry.PullNumber, 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", "--pretty=format:%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", 2)
|
||||||
|
subject := strings.TrimSpace(parts[0])
|
||||||
|
fallbackAuthor := ""
|
||||||
|
if len(parts) > 1 {
|
||||||
|
fallbackAuthor = strings.TrimSpace(parts[1])
|
||||||
|
}
|
||||||
|
matches := pullNumberPattern.FindStringSubmatch(subject)
|
||||||
|
if len(matches) != 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pullNumber := matches[1]
|
||||||
|
if _, exists := seen[pullNumber]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[pullNumber] = 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{
|
||||||
|
Title: sanitizeSubject(subject),
|
||||||
|
PullNumber: pullNumber,
|
||||||
|
AuthorName: authorName,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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].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 (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 ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package changelog
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestSanitizeSubject(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
subject string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "merge prefix with quotes",
|
||||||
|
subject: "Merge pull request 'Add classes overhaul (#123)'",
|
||||||
|
want: "Add classes overhaul",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "plain subject",
|
||||||
|
subject: "Add classes overhaul (#123)",
|
||||||
|
want: "Add classes overhaul",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "double quotes",
|
||||||
|
subject: `Merge pull request "Add classes overhaul (#123)"`,
|
||||||
|
want: "Add classes overhaul",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
tt := tt
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
if got := sanitizeSubject(tt.subject); got != tt.want {
|
||||||
|
t.Fatalf("sanitizeSubject(%q) = %q, want %q", tt.subject, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOwnerAndRepoFromURL(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
owner, repo, err := ownerAndRepoFromURL("https://gitea.example.test/org/repo.git")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ownerAndRepoFromURL returned error: %v", err)
|
||||||
|
}
|
||||||
|
if owner != "org" || repo != "repo" {
|
||||||
|
t.Fatalf("ownerAndRepoFromURL returned %q/%q", owner, repo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeriveAPIBaseURL(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
got := deriveAPIBaseURL("https://gitea.example.test/org/repo")
|
||||||
|
want := "https://gitea.example.test/api/v1"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("deriveAPIBaseURL() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeAPIBaseURL(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{input: "https://gitea.example.test", want: "https://gitea.example.test/api/v1"},
|
||||||
|
{input: "https://gitea.example.test/api/v1", want: "https://gitea.example.test/api/v1"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
if got := normalizeAPIBaseURL(tt.input); got != tt.want {
|
||||||
|
t.Fatalf("normalizeAPIBaseURL(%q) = %q, want %q", tt.input, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user