diff --git a/CHANGELOG_AUTOMATION_CONTRACT.md b/CHANGELOG_AUTOMATION_CONTRACT.md new file mode 100644 index 0000000..497f2aa --- /dev/null +++ b/CHANGELOG_AUTOMATION_CONTRACT.md @@ -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 `: repo-local JSON section config +- `--output `: write rendered Markdown to a file; otherwise stdout +- `--current-tag `: optional explicit current tag +- `--previous-tag `: optional explicit previous tag +- `--api-base-url `: optional explicit Gitea API base URL +- `--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 diff --git a/internal/app/app.go b/internal/app/app.go index bc757ff..5183fb3 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -11,6 +11,7 @@ import ( "sync" "time" + "gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/changelog" "gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/pipeline" "gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project" "gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/topdata" @@ -158,6 +159,11 @@ var commands = []command{ description: "Deploy generated wiki pages to NodeBB.", 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) { @@ -830,6 +836,86 @@ func parseDeployWikiArgs(commandName string, args []string) (topdata.DeployWikiO 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 ] [--output ] [--current-tag ] [--previous-tag ] [--api-base-url ] [--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 { for _, part := range strings.Split(raw, ",") { part = strings.TrimSpace(part) diff --git a/internal/changelog/changelog.go b/internal/changelog/changelog.go new file mode 100644 index 0000000..81ee71c --- /dev/null +++ b/internal/changelog/changelog.go @@ -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 "" +} diff --git a/internal/changelog/changelog_test.go b/internal/changelog/changelog_test.go new file mode 100644 index 0000000..4addf37 --- /dev/null +++ b/internal/changelog/changelog_test.go @@ -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) + } + } +}