hack/tools/changelog/main.go
Rama main · réplica de lectura del repositorio original.
// Copyright 2021-2026 Zenauth Ltd.
package main
import (
"cmp"
"embed"
"errors"
"fmt"
"log"
"os"
"path/filepath"
"slices"
"strings"
"text/template"
"time"
"github.com/alecthomas/kong"
"github.com/go-git/go-git/v6"
"github.com/go-git/go-git/v6/plumbing"
"github.com/go-git/go-git/v6/plumbing/object"
"github.com/go-git/go-git/v6/utils/merkletrie"
)
//go:embed templates
var templatesFS embed.FS
const changelogDirName = ".changelog"
type args struct {
Add addCmd `cmd:""`
Generate generateCmd `cmd:""`
}
type addCmd struct {
Type string `help:"Type of the entry" enum:"breaking,chore,deprecation,docs,enhancement,feature,fix" required:""`
Description string `help:"Change description" required:""`
}
type generateCmd struct {
From string `help:"Reference to start of change log" required:""`
Date string `help:"Release date" optional:""`
NewVersion string `help:"New release version" required:""`
}
func main() {
var args args
ctx := kong.Parse(&args,
kong.Name("changelog"),
kong.Description("Manage changelog"),
kong.UsageOnError(),
)
ctx.FatalIfErrorf(ctx.Run())
}
func (ac *addCmd) Run(k *kong.Kong) error {
workingDir, err := os.Getwd()
if err != nil {
return fmt.Errorf("failed to get current working directory: %w", err)
}
changelogDir := filepath.Join(workingDir, changelogDirName)
if err := os.MkdirAll(changelogDir, 0o744); err != nil {
return fmt.Errorf("failed to create changelog dir at %q: %w", changelogDir, err)
}
entryTemplate, err := template.ParseFS(templatesFS, "templates/entry.tmpl")
if err != nil {
return fmt.Errorf("failed to parse templates: %w", err)
}
entryFileName := filepath.Join(changelogDir, time.Now().UTC().Format("20060102_1504")+".txt")
entryFile, err := os.Create(entryFileName)
if err != nil {
return fmt.Errorf("failed to create entry file %q: %w", entryFileName, err)
}
defer entryFile.Close()
if err := entryTemplate.Execute(entryFile, struct {
Type string
Description string
}{
Type: ac.Type,
Description: ac.Description,
}); err != nil {
return fmt.Errorf("failed to render entry: %w", err)
}
fmt.Fprintf(k.Stdout, "Changelog entry written to %s\n", entryFileName)
return nil
}
type ChangelogSection struct {
Title string
Type string
Entries []entry
}
type Changelog struct {
Version string
Date string
Sections []ChangelogSection
}
func (gc *generateCmd) Run(k *kong.Kong) error {
workingDir, err := os.Getwd()
if err != nil {
return fmt.Errorf("failed to get current working directory: %w", err)
}
fromRevision := plumbing.Revision(gc.From)
entries, err := getChangelogEntries(workingDir, fromRevision)
if err != nil {
return err
}
releaseDate := gc.Date
if releaseDate == "" {
releaseDate = time.Now().Format(time.DateOnly)
}
changelogData := Changelog{Version: gc.NewVersion, Date: releaseDate}
for _, section := range []struct {
Type string
Title string
}{
{
Type: "breaking",
Title: "Breaking changes",
},
{
Type: "deprecation",
Title: "Deprecations",
},
{
Type: "feature",
Title: "Features",
},
{
Type: "enhancement",
Title: "Enhancements",
},
{
Type: "fix",
Title: "Bug fixes",
},
{
Type: "docs",
Title: "Documentation",
},
{
Type: "chore",
Title: "Other",
},
} {
if entryList, exists := entries[section.Type]; exists {
slices.SortFunc(entryList, func(a, b entry) int {
return cmp.Compare(a.Name, b.Name)
})
changelogData.Sections = append(changelogData.Sections, ChangelogSection{
Title: section.Title,
Type: section.Type,
Entries: entryList,
})
}
}
changelogTemplate, err := template.ParseFS(templatesFS, "templates/changelog.tmpl")
if err != nil {
return fmt.Errorf("failed to parse changelog template: %w", err)
}
if err := changelogTemplate.Execute(k.Stdout, changelogData); err != nil {
return fmt.Errorf("failed to render template: %w", err)
}
return nil
}
type entry struct {
Name string
Type string
Description string
}
func getChangelogEntries(workingDir string, fromRevision plumbing.Revision) (map[string][]entry, error) {
repo, err := git.PlainOpen(workingDir)
if err != nil {
return nil, fmt.Errorf("failed to open git repo: %w", err)
}
defer repo.Close()
fromTree, err := getTree(repo, fromRevision)
if err != nil {
return nil, fmt.Errorf("failed to get from tree: %w", err)
}
toTree, err := getTree(repo, plumbing.Revision(plumbing.HEAD))
if err != nil {
return nil, fmt.Errorf("failed to get to tree: %w", err)
}
changes, err := fromTree.Diff(toTree)
if err != nil {
return nil, fmt.Errorf("failed to diff: %w", err)
}
changelogFiles := make(map[string]struct{})
for _, change := range changes {
action, err := change.Action()
if err != nil {
return nil, fmt.Errorf("failed to get action for change: %w", err)
}
switch action {
case merkletrie.Insert, merkletrie.Modify:
if filepath.Base(filepath.Dir(change.To.Name)) == changelogDirName {
changelogFiles[change.To.Name] = struct{}{}
}
}
}
entries := make(map[string][]entry)
for file := range changelogFiles {
entry, err := createEntry(file)
if err != nil {
log.Printf("Failed to create entry from %s: %v", file, err)
continue
}
entries[entry.Type] = append(entries[entry.Type], entry)
}
return entries, nil
}
func getTree(repo *git.Repository, revision plumbing.Revision) (*object.Tree, error) {
commitHash, err := repo.ResolveRevision(revision)
if err != nil {
return nil, fmt.Errorf("failed to resolve revision %s: %w", revision, err)
}
commit, err := repo.CommitObject(*commitHash)
if err != nil {
return nil, fmt.Errorf("failed to get commit: %w", err)
}
return commit.Tree()
}
func createEntry(fileName string) (entry, error) {
contents, err := os.ReadFile(fileName)
if err != nil {
return entry{}, fmt.Errorf("failed to read file: %w", err)
}
lines := slices.Collect(strings.Lines(string(contents)))
if len(lines) < 2 || !strings.HasPrefix(lines[0], "type:") {
return entry{}, errors.New("invalid changelog entry")
}
entryType := strings.TrimSpace(strings.TrimPrefix(lines[0], "type:"))
var description string
if len(lines) > 2 {
// Use Asciidoc continuation for complex Descriptions.
description = fmt.Sprintf("%s:: %s\n", strings.TrimSpace(lines[1]), strings.Join(lines[2:], ""))
} else {
description = fmt.Sprintf("%s:: {empty}\n", strings.TrimSpace(lines[1]))
}
return entry{
Name: strings.TrimSuffix(filepath.Base(fileName), ".txt"),
Type: entryType,
Description: description,
}, nil
}