func (e Entry) Valid() error {
	if e.Type == "" {
		return errors.New("entry title does not have a prefix, example: Bugfix: restore old behavior")
	}
	if e.Title == "" {
		return errors.New("entry does not have a title")
	}
	if e.PrimaryID == "" {
		return errors.New("primary issue ID not found")
	}
	lastChar := e.Title[len(e.Title)-1]
	if strings.ContainsAny(string(lastChar), Punctuation) {
		return fmt.Errorf("title ends with punctuation, e.g. a character out of %q", Punctuation)
	}
	if _, ok := EntryTypePriority[e.Type]; !ok {
		return fmt.Errorf("entry type %q is invalid, valid types: %v", e.Type, EntryTypePriority)
	}
	if len(e.Type)+len(e.Title)+1 > 80 {
		return errors.New("title is too long (max 80 characters)")
	}
	return nil
}
Valid returns an error if the entry is invalid in any way.
package main
import (
	"bufio"
	"errors"
	"fmt"
	"io/ioutil"
	"net/url"
	"os"
	"path/filepath"
	"regexp"
	"sort"
	"strings"
	"time"
	"text/template"
	"github.com/Masterminds/sprig/v3"
	"github.com/spf13/pflag"
)
var opts struct {
	Output       string
	InputDir     string
	TemplateFile string
	Versions     []string
}
func init() {
	pflag.StringVarP(&opts.InputDir, "input", "i", "changelog", "read input files from `dir`")
	pflag.StringVarP(&opts.Output, "output", "o", "", "write generated changelog to this `file` (default: print to stdout)")
	pflag.StringVarP(&opts.TemplateFile, "template", "t", filepath.FromSlash("changelog/CHANGELOG.tmpl"), "read template from `file`")
	pflag.StringSliceVar(&opts.Versions, "version", nil, "only print `version` (separate multiple versions with commas)")
	pflag.Parse()
}
func die(msg string, args ...interface{}) {
	if !strings.HasSuffix(msg, "\\n") {
		msg += "\n"
	}
	fmt.Fprintf(os.Stderr, msg, args...)
	os.Exit(1)
}
func files(dir string) []string {
	d, err := os.Open(dir)
	if err != nil {
		die("error opening dir: %v", err)
	}
	names, err := d.Readdirnames(-1)
	if err != nil {
		_ = d.Close()
		die("error listing dir: %v", err)
	}
	err = d.Close()
	if err != nil {
		die("error closing dir: %v", err)
	}
	sort.Strings(names)
	var files []string
	for _, name := range names {
		if name == "TEMPLATE" || name == "releases" {
			continue
		}
		if strings.HasPrefix(name, ".") {
			continue
		}
		files = append(files, filepath.Join(dir, name))
	}
	return files
}
type Release struct {
	path    string
	Version string
	Date    *time.Time
}
type ReleaseSlice []Release
func (s ReleaseSlice) Len() int {
	return len(s)
}
func (s ReleaseSlice) Less(i, j int) bool {
	if s[i].Date == nil {
		return true
	}
	if s[j].Date == nil {
		return false
	}
	return s[j].Date.Before(*s[i].Date)
}
func (s ReleaseSlice) Swap(i, j int) {
	s[i], s[j] = s[j], s[i]
}
var versionRegex = regexp.MustCompile(`^(\d+\.\d+\.\d+)(_(\d{4}-\d{2}-\d{2}))?$`)
func readReleases(dir string) (result []Release) {
	f, err := os.Open(dir)
	if err != nil {
		die("unable to open dir: %v", err)
	}
	entries, err := f.Readdir(-1)
	if err != nil {
		die("unable to list directory: %v", err)
	}
	err = f.Close()
	if err != nil {
		die("close dir: %v", err)
	}
	for _, entry := range entries {
		if !entry.Mode().IsDir() {
			continue
		}
		if entry.Name() == "unreleased" {
			rel := Release{
				path:    filepath.Join(dir, entry.Name()),
				Version: "unreleased",
			}
			result = append(result, rel)
			continue
		}
		data := versionRegex.FindStringSubmatch(entry.Name())
		if len(data) == 0 {
			die("invalid subdir name %v", filepath.Join(dir, entry.Name()))
			continue
		}
		ver := data[1]
		date := data[3]
		rel := Release{
			path:    filepath.Join(dir, entry.Name()),
			Version: ver,
		}
		if date != "" {
			t, err := time.Parse("2006-01-02", date)
			if err != nil {
				die("unable to parse date %q: %v", date, err)
			}
			rel.Date = &t
		}
		result = append(result, rel)
	}
	sort.Sort(ReleaseSlice(result))
	return result
}
type Entry struct {
	Type       string
	TypeShort  string
	Title      string
	Paragraphs []string
	URLs       []*url.URL
	Issues     []string
	IssueURLs  []*url.URL
	PRs        []string
	PRURLs     []*url.URL
	OtherURLs  []*url.URL
	PrimaryID  string
	PrimaryURL *url.URL
}
var EntryTypePriority = map[string]int{
	"Security":    1,
	"Bugfix":      2,
	"Change":      3,
	"Enhancement": 4,
}
var EntryTypeAbbreviation = map[string]string{
	"Security":    "Sec",
	"Bugfix":      "Fix",
	"Change":      "Chg",
	"Enhancement": "Enh",
}
type EntrySlice []Entry
func (s EntrySlice) Len() int {
	return len(s)
}
func (s EntrySlice) Less(i, j int) bool {
	return EntryTypePriority[s[i].Type] < EntryTypePriority[s[j].Type]
}
func (s EntrySlice) Swap(i, j int) {
	s[i], s[j] = s[j], s[i]
}
const Punctuation = ".!?"