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 "" }