Phase 5: scaffold Crucible (sow-tools) — dispatcher + builder shims, container, PR-first CI, fail-closed (D11, D19).
This commit is contained in:
@@ -1,455 +0,0 @@
|
||||
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 ""
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
package changelog
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"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",
|
||||
},
|
||||
{
|
||||
name: "direct push",
|
||||
subject: "Fix direct push handling",
|
||||
want: "Fix direct push handling",
|
||||
},
|
||||
}
|
||||
|
||||
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 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()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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