diff --git a/README.md b/README.md index 684416ad..8c08c286 100644 --- a/README.md +++ b/README.md @@ -461,6 +461,15 @@ Analytics are stored locally and help identify workflow patterns. Disable with ` | Query issues | `td query "expression"` | | Search text | `td search "keyword"` | +### System Commands + +| Action | Command | +| ------------------- | -------------------------------------------------------------- | +| Initialize project | `td init` | +| Show version | `td version` | +| Draft release notes | `td release-notes --version vX.Y.Z [--from ref] [--to ref]` | +| JSON release draft | `td release-notes --json --include-internal --version vX.Y.Z` | + ## Live Monitor > Full documentation: [Live Monitor](https://marcus.github.io/td/docs/monitor) diff --git a/cmd/release_notes.go b/cmd/release_notes.go new file mode 100644 index 00000000..bce6026f --- /dev/null +++ b/cmd/release_notes.go @@ -0,0 +1,73 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "os" + + "github.com/marcus/td/internal/releasenotes" + "github.com/spf13/cobra" +) + +func newReleaseNotesCmd() *cobra.Command { + var opts releasenotes.Options + var jsonOutput bool + + cmd := &cobra.Command{ + Use: "release-notes", + Short: "Draft release notes from git commits", + GroupID: "system", + RunE: func(cmd *cobra.Command, args []string) error { + repoDir, err := releaseNotesStartDir() + if err != nil { + return err + } + opts.RepoDir = repoDir + + draft, err := releasenotes.DraftFromGit(context.Background(), opts) + if err != nil { + return err + } + + out := cmd.OutOrStdout() + if jsonOutput { + enc := json.NewEncoder(out) + enc.SetIndent("", " ") + return enc.Encode(draft) + } + + _, err = fmt.Fprint(out, releasenotes.RenderMarkdown(draft)) + return err + }, + } + + cmd.Flags().StringVar(&opts.From, "from", "", "Base git ref (default: latest v* tag before --to)") + cmd.Flags().StringVar(&opts.To, "to", "HEAD", "Target git ref") + cmd.Flags().StringVar(&opts.Version, "version", "", "Release version heading, for example v0.5.0") + cmd.Flags().StringVar(&opts.Date, "date", "", "Release date in YYYY-MM-DD format") + cmd.Flags().BoolVar(&jsonOutput, "json", false, "Emit structured JSON") + cmd.Flags().BoolVar(&opts.IncludeInternal, "include-internal", false, "Include internal maintenance commits") + + return cmd +} + +func releaseNotesStartDir() (string, error) { + if workDirFlag != "" { + return normalizeWorkDir(workDirFlag), nil + } + if envDir := os.Getenv("TD_WORK_DIR"); envDir != "" { + return normalizeWorkDir(envDir), nil + } + dir, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("cannot determine working directory: %w", err) + } + return dir, nil +} + +var releaseNotesCmd = newReleaseNotesCmd() + +func init() { + rootCmd.AddCommand(releaseNotesCmd) +} diff --git a/cmd/release_notes_test.go b/cmd/release_notes_test.go new file mode 100644 index 00000000..fa5c4d1c --- /dev/null +++ b/cmd/release_notes_test.go @@ -0,0 +1,251 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/marcus/td/internal/releasenotes" +) + +func TestReleaseNotesDefaultRangeUsesLatestVersionTag(t *testing.T) { + repo := newReleaseNotesRepo(t) + commitFile(t, repo, "README.md", "initial\n", "feat: initial release") + runGit(t, repo, "tag", "v0.1.0") + featureSHA := commitFile(t, repo, "feature.txt", "feature\n", "feat(cli): add release notes") + + out, err := runReleaseNotesCommand(t, repo, "--version", "v0.2.0") + if err != nil { + t.Fatalf("release-notes failed: %v", err) + } + + for _, want := range []string{ + "## v0.2.0", + "_Range: `v0.1.0..HEAD` (1 commits)_", + "### Features", + "- add release notes (" + featureSHA + ")", + } { + if !strings.Contains(out, want) { + t.Fatalf("output missing %q:\n%s", want, out) + } + } + if strings.Contains(out, "initial release") { + t.Fatalf("default range should exclude tagged commit:\n%s", out) + } +} + +func TestReleaseNotesExplicitFromTo(t *testing.T) { + repo := newReleaseNotesRepo(t) + commitFile(t, repo, "README.md", "initial\n", "feat: initial release") + runGit(t, repo, "tag", "v0.1.0") + featureSHA := commitFile(t, repo, "feature.txt", "feature\n", "feat: add board export") + fixSHA := commitFile(t, repo, "fix.txt", "fix\n", "fix: handle missing board") + commitFile(t, repo, "docs.txt", "docs\n", "docs: explain board export") + + out, err := runReleaseNotesCommand(t, repo, "--from", "v0.1.0", "--to", fixSHA) + if err != nil { + t.Fatalf("release-notes failed: %v", err) + } + + for _, want := range []string{ + "_Range: `v0.1.0.." + fixSHA + "` (2 commits)_", + "- add board export (" + featureSHA + ")", + "- handle missing board (" + fixSHA + ")", + } { + if !strings.Contains(out, want) { + t.Fatalf("output missing %q:\n%s", want, out) + } + } + if strings.Contains(out, "explain board export") { + t.Fatalf("explicit --to should exclude later commit:\n%s", out) + } +} + +func TestReleaseNotesDefaultRangeUsesHistoricalToRef(t *testing.T) { + repo := newReleaseNotesRepo(t) + commitFile(t, repo, "README.md", "initial\n", "feat: initial release") + runGit(t, repo, "tag", "v0.1.0") + historicalSHA := commitFile(t, repo, "feature.txt", "feature\n", "feat: add historical feature") + commitFile(t, repo, "fix.txt", "fix\n", "fix: handle later bug") + runGit(t, repo, "tag", "v0.2.0") + commitFile(t, repo, "future.txt", "future\n", "feat: add future feature") + runGit(t, repo, "tag", "v0.3.0") + + out, err := runReleaseNotesCommand(t, repo, "--to", historicalSHA, "--version", "v0.2.0") + if err != nil { + t.Fatalf("release-notes failed: %v", err) + } + + for _, want := range []string{ + "_Range: `v0.1.0.." + historicalSHA + "` (1 commits)_", + "- add historical feature (" + historicalSHA + ")", + } { + if !strings.Contains(out, want) { + t.Fatalf("output missing %q:\n%s", want, out) + } + } + for _, unwanted := range []string{"handle later bug", "add future feature"} { + if strings.Contains(out, unwanted) { + t.Fatalf("historical --to should exclude %q:\n%s", unwanted, out) + } + } +} + +func TestReleaseNotesDefaultRangeForTaggedToUsesPreviousVersionTag(t *testing.T) { + repo := newReleaseNotesRepo(t) + commitFile(t, repo, "README.md", "initial\n", "feat: initial release") + runGit(t, repo, "tag", "v0.1.0") + featureSHA := commitFile(t, repo, "feature.txt", "feature\n", "feat: add tagged release feature") + fixSHA := commitFile(t, repo, "fix.txt", "fix\n", "fix: handle tagged release bug") + runGit(t, repo, "tag", "v0.2.0") + commitFile(t, repo, "future.txt", "future\n", "feat: add future feature") + runGit(t, repo, "tag", "v0.3.0") + + out, err := runReleaseNotesCommand(t, repo, "--to", "v0.2.0", "--version", "v0.2.0") + if err != nil { + t.Fatalf("release-notes failed: %v", err) + } + + for _, want := range []string{ + "_Range: `v0.1.0..v0.2.0` (2 commits)_", + "- add tagged release feature (" + featureSHA + ")", + "- handle tagged release bug (" + fixSHA + ")", + } { + if !strings.Contains(out, want) { + t.Fatalf("output missing %q:\n%s", want, out) + } + } + if strings.Contains(out, "add future feature") { + t.Fatalf("tagged --to should exclude future commits:\n%s", out) + } +} + +func TestReleaseNotesJSONOutput(t *testing.T) { + repo := newReleaseNotesRepo(t) + commitFile(t, repo, "README.md", "initial\n", "feat: initial release") + runGit(t, repo, "tag", "v0.1.0") + commitFile(t, repo, "fix.txt", "fix\n", "fix(api): handle empty release range") + + out, err := runReleaseNotesCommand(t, repo, "--from", "v0.1.0", "--json", "--version", "v0.2.0") + if err != nil { + t.Fatalf("release-notes failed: %v", err) + } + + var draft releasenotes.Draft + if err := json.Unmarshal([]byte(out), &draft); err != nil { + t.Fatalf("invalid JSON: %v\n%s", err, out) + } + if draft.Version != "v0.2.0" || draft.From != "v0.1.0" || draft.To != "HEAD" { + t.Fatalf("unexpected draft metadata: %+v", draft) + } + if draft.CommitCount != 1 { + t.Fatalf("commit count = %d, want 1", draft.CommitCount) + } + items := releaseNotesSectionItems(draft, releasenotes.SectionFixes) + if len(items) != 1 { + t.Fatalf("bug fix items = %d, want 1", len(items)) + } + if items[0].Subject != "handle empty release range" || items[0].Scope != "api" { + t.Fatalf("unexpected bug fix item: %+v", items[0]) + } +} + +func TestReleaseNotesEmptyRange(t *testing.T) { + repo := newReleaseNotesRepo(t) + commitFile(t, repo, "README.md", "initial\n", "feat: initial release") + + out, err := runReleaseNotesCommand(t, repo, "--from", "HEAD", "--to", "HEAD") + if err != nil { + t.Fatalf("release-notes failed: %v", err) + } + if !strings.Contains(out, "_Range: `HEAD..HEAD` (0 commits)_") { + t.Fatalf("missing empty range metadata:\n%s", out) + } + if !strings.Contains(out, "_No release note entries found._") { + t.Fatalf("missing empty draft message:\n%s", out) + } +} + +func TestReleaseNotesNoTagsRequiresFrom(t *testing.T) { + repo := newReleaseNotesRepo(t) + commitFile(t, repo, "README.md", "initial\n", "feat: initial release") + + _, err := runReleaseNotesCommand(t, repo) + if err == nil { + t.Fatal("expected error without v* tags or --from") + } + if !strings.Contains(err.Error(), "no v* tags found") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestReleaseNotesInvalidDate(t *testing.T) { + repo := newReleaseNotesRepo(t) + commitFile(t, repo, "README.md", "initial\n", "feat: initial release") + + _, err := runReleaseNotesCommand(t, repo, "--from", "HEAD", "--to", "HEAD", "--date", "05/08/2026") + if err == nil { + t.Fatal("expected invalid date error") + } + if !strings.Contains(err.Error(), "invalid --date") { + t.Fatalf("unexpected error: %v", err) + } +} + +func runReleaseNotesCommand(t *testing.T, repo string, args ...string) (string, error) { + t.Helper() + saveAndRestoreGlobals(t) + workDirFlag = "" + t.Setenv("TD_WORK_DIR", "") + t.Chdir(repo) + + cmd := newReleaseNotesCmd() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs(args) + err := cmd.Execute() + return out.String(), err +} + +func newReleaseNotesRepo(t *testing.T) string { + t.Helper() + repo := initGitRepo(t) + runGit(t, repo, "config", "user.email", "td@example.com") + runGit(t, repo, "config", "user.name", "td tests") + return repo +} + +func commitFile(t *testing.T, repo, name, content, subject string) string { + t.Helper() + path := filepath.Join(repo, name) + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } + runGit(t, repo, "add", name) + runGit(t, repo, "commit", "-m", subject) + return gitOutput(t, repo, "rev-parse", "--short", "HEAD") +} + +func gitOutput(t *testing.T, repo string, args ...string) string { + t.Helper() + cmd := exec.Command("git", append([]string{"-C", repo}, args...)...) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %s failed: %v (%s)", strings.Join(args, " "), err, strings.TrimSpace(string(out))) + } + return strings.TrimSpace(string(out)) +} + +func releaseNotesSectionItems(draft releasenotes.Draft, id string) []releasenotes.Item { + for _, section := range draft.Sections { + if section.ID == id { + return section.Items + } + } + return nil +} diff --git a/docs/guides/releasing-new-version.md b/docs/guides/releasing-new-version.md index ca98e527..c6f98bb7 100644 --- a/docs/guides/releasing-new-version.md +++ b/docs/guides/releasing-new-version.md @@ -33,7 +33,16 @@ Check current version: git tag -l | sort -V | tail -1 ``` -### 2. Update CHANGELOG.md +### 2. Draft and Update CHANGELOG.md + +Draft release notes from commits since the latest `v*` tag before the target release: + +```bash +td release-notes --version vX.Y.Z +# Optional: add --to vX.Y.Z, --date YYYY-MM-DD, or --json for structured review +``` + +Review and curate the generated notes, then paste the result into the top of `CHANGELOG.md` or into GitHub release notes. The drafter is local-only: it reads git commits and does not edit the changelog, create tags, publish releases, or call external services. Add entry at the top of `CHANGELOG.md`: @@ -135,7 +144,8 @@ git status go test ./... # Update changelog -# (Edit CHANGELOG.md, add entry at top) +# Draft notes, then edit CHANGELOG.md and add the curated entry at top +td release-notes --version vX.Y.Z git add CHANGELOG.md git commit -m "docs: Update changelog for vX.Y.Z" @@ -154,6 +164,7 @@ brew upgrade td && td version - [ ] Tests pass (`go test ./...`) - [ ] Working tree clean +- [ ] Release notes drafted with `td release-notes --version vX.Y.Z` - [ ] CHANGELOG.md updated with new version entry - [ ] Changelog committed to git - [ ] Version number follows semver diff --git a/internal/releasenotes/releasenotes.go b/internal/releasenotes/releasenotes.go new file mode 100644 index 00000000..6b8fad06 --- /dev/null +++ b/internal/releasenotes/releasenotes.go @@ -0,0 +1,425 @@ +package releasenotes + +import ( + "bytes" + "context" + "errors" + "fmt" + "os/exec" + "regexp" + "strings" + "time" +) + +const ( + SectionBreaking = "breaking_changes" + SectionFeatures = "features" + SectionFixes = "bug_fixes" + SectionPerformance = "performance" + SectionImprovements = "improvements" + SectionDocs = "documentation" + SectionMaintenance = "maintenance" +) + +var ( + ErrNotGitRepo = errors.New("not a git repository") + + conventionalSubjectRe = regexp.MustCompile(`^([A-Za-z][A-Za-z0-9-]*)(?:\(([^)]+)\))?(!)?:\s*(.+)$`) + breakingFooterRe = regexp.MustCompile(`(?im)^BREAKING[ -]CHANGE:\s*(.+)$`) +) + +type Options struct { + RepoDir string + From string + To string + Version string + Date string + IncludeInternal bool +} + +type Commit struct { + Hash string + ShortHash string + Subject string + Body string +} + +type ParsedSubject struct { + Type string + Scope string + Description string + Conventional bool + Breaking bool +} + +type Item struct { + SHA string `json:"sha"` + Subject string `json:"subject"` + Type string `json:"type,omitempty"` + Scope string `json:"scope,omitempty"` + Breaking bool `json:"breaking,omitempty"` + BreakingNote string `json:"breaking_note,omitempty"` + Internal bool `json:"internal,omitempty"` +} + +type Section struct { + ID string `json:"id"` + Title string `json:"title"` + Items []Item `json:"items"` +} + +type Draft struct { + Version string `json:"version,omitempty"` + Date string `json:"date,omitempty"` + From string `json:"from"` + To string `json:"to"` + Repository string `json:"repository,omitempty"` + CommitCount int `json:"commit_count"` + Sections []Section `json:"sections"` +} + +type sectionDef struct { + id string + title string +} + +var orderedSections = []sectionDef{ + {SectionBreaking, "Breaking Changes"}, + {SectionFeatures, "Features"}, + {SectionFixes, "Bug Fixes"}, + {SectionPerformance, "Performance"}, + {SectionImprovements, "Improvements"}, + {SectionDocs, "Documentation"}, + {SectionMaintenance, "Maintenance"}, +} + +func DraftFromGit(ctx context.Context, opts Options) (*Draft, error) { + if err := ValidateDate(opts.Date); err != nil { + return nil, err + } + if strings.TrimSpace(opts.To) == "" { + opts.To = "HEAD" + } + + repo, err := GitRoot(ctx, opts.RepoDir) + if err != nil { + return nil, err + } + + if err := VerifyCommitRef(ctx, repo, opts.To); err != nil { + return nil, fmt.Errorf("invalid --to ref %q: %w", opts.To, err) + } + + if strings.TrimSpace(opts.From) == "" { + tag, err := DefaultBaseRef(ctx, repo, opts.To) + if err != nil { + return nil, err + } + opts.From = tag + } + + if err := VerifyCommitRef(ctx, repo, opts.From); err != nil { + return nil, fmt.Errorf("invalid --from ref %q: %w", opts.From, err) + } + + commits, err := CollectCommits(ctx, repo, opts.From, opts.To) + if err != nil { + return nil, err + } + + return BuildDraft(commits, Draft{ + Version: strings.TrimSpace(opts.Version), + Date: strings.TrimSpace(opts.Date), + From: opts.From, + To: opts.To, + Repository: repo, + }, opts.IncludeInternal), nil +} + +func ValidateDate(value string) error { + if strings.TrimSpace(value) == "" { + return nil + } + if _, err := time.Parse("2006-01-02", value); err != nil { + return fmt.Errorf("invalid --date %q: use YYYY-MM-DD", value) + } + return nil +} + +func GitRoot(ctx context.Context, dir string) (string, error) { + if strings.TrimSpace(dir) == "" { + dir = "." + } + out, err := runGit(ctx, dir, "rev-parse", "--show-toplevel") + if err != nil { + return "", fmt.Errorf("%w: %s", ErrNotGitRepo, dir) + } + return strings.TrimSpace(string(out)), nil +} + +func DefaultBaseRef(ctx context.Context, repoDir, toRef string) (string, error) { + describeRef := toRef + if pointsAtVersionTag(ctx, repoDir, toRef) { + describeRef = toRef + "^" + } + + out, err := runGit(ctx, repoDir, "describe", "--tags", "--abbrev=0", "--match", "v[0-9]*", describeRef) + if err != nil { + return "", fmt.Errorf("no v* tags found; pass --from to choose a base ref") + } + return strings.TrimSpace(string(out)), nil +} + +func pointsAtVersionTag(ctx context.Context, repoDir, ref string) bool { + out, err := runGit(ctx, repoDir, "describe", "--exact-match", "--tags", "--match", "v[0-9]*", ref) + return err == nil && strings.TrimSpace(string(out)) != "" +} + +func VerifyCommitRef(ctx context.Context, repoDir, ref string) error { + if strings.TrimSpace(ref) == "" { + return errors.New("empty ref") + } + _, err := runGit(ctx, repoDir, "rev-parse", "--verify", "--quiet", ref+"^{commit}") + if err != nil { + return errors.New("ref does not resolve to a commit") + } + return nil +} + +func CollectCommits(ctx context.Context, repoDir, fromRef, toRef string) ([]Commit, error) { + raw, err := runGit(ctx, repoDir, "log", "--no-merges", "--format=%x1e%H%x1f%h%x1f%s%x1f%B", fromRef+".."+toRef) + if err != nil { + return nil, fmt.Errorf("failed to read git commits for %s..%s: %w", fromRef, toRef, err) + } + commits, err := parseGitLog(raw) + if err != nil { + return nil, err + } + + // git log is newest-first; reverse for changelog-style oldest-first output. + for i, j := 0, len(commits)-1; i < j; i, j = i+1, j-1 { + commits[i], commits[j] = commits[j], commits[i] + } + return commits, nil +} + +func BuildDraft(commits []Commit, base Draft, includeInternal bool) *Draft { + sections := make([]Section, 0, len(orderedSections)) + sectionIndex := make(map[string]int, len(orderedSections)) + for _, def := range orderedSections { + sectionIndex[def.id] = len(sections) + sections = append(sections, Section{ID: def.id, Title: def.title, Items: []Item{}}) + } + + commitCount := 0 + for _, commit := range commits { + item, sectionID, ok := Classify(commit) + if !ok { + continue + } + if item.Internal && !item.Breaking && !includeInternal { + continue + } + idx := sectionIndex[sectionID] + sections[idx].Items = append(sections[idx].Items, item) + commitCount++ + } + + base.CommitCount = commitCount + base.Sections = sections + return &base +} + +func ParseSubject(subject string) ParsedSubject { + subject = strings.TrimSpace(subject) + matches := conventionalSubjectRe.FindStringSubmatch(subject) + if matches == nil { + return ParsedSubject{ + Description: subject, + Conventional: false, + } + } + return ParsedSubject{ + Type: strings.ToLower(matches[1]), + Scope: matches[2], + Breaking: matches[3] == "!", + Description: strings.TrimSpace(matches[4]), + Conventional: true, + } +} + +func Classify(commit Commit) (Item, string, bool) { + if shouldSkipSubject(commit.Subject) { + return Item{}, "", false + } + + parsed := ParseSubject(commit.Subject) + breakingNote := breakingNote(commit.Body) + breaking := parsed.Breaking || breakingNote != "" + sectionID, internal := sectionForType(parsed.Type, parsed.Conventional) + if breaking { + sectionID = SectionBreaking + } + + subject := parsed.Description + if strings.TrimSpace(subject) == "" { + subject = strings.TrimSpace(commit.Subject) + } + if subject == "" { + subject = "(no subject)" + } + + return Item{ + SHA: commit.ShortHash, + Subject: subject, + Type: parsed.Type, + Scope: parsed.Scope, + Breaking: breaking, + BreakingNote: breakingNote, + Internal: internal, + }, sectionID, true +} + +func RenderMarkdown(draft *Draft) string { + var b strings.Builder + header := "## Release Notes" + if draft.Version != "" { + header = "## " + draft.Version + } + if draft.Date != "" { + header += " - " + draft.Date + } + b.WriteString(header) + b.WriteString("\n\n") + + if draft.From != "" || draft.To != "" { + b.WriteString(fmt.Sprintf("_Range: `%s..%s` (%d commits)_\n\n", draft.From, draft.To, draft.CommitCount)) + } + + hasItems := false + for _, section := range draft.Sections { + if len(section.Items) == 0 { + continue + } + hasItems = true + b.WriteString("### ") + b.WriteString(section.Title) + b.WriteString("\n\n") + for _, item := range section.Items { + b.WriteString("- ") + b.WriteString(item.Subject) + if item.SHA != "" { + b.WriteString(" (") + b.WriteString(item.SHA) + b.WriteString(")") + } + b.WriteString("\n") + } + b.WriteString("\n") + } + + if !hasItems { + b.WriteString("_No release note entries found._\n") + } + + return strings.TrimRight(b.String(), "\n") + "\n" +} + +func SectionTitles() []string { + titles := make([]string, 0, len(orderedSections)) + for _, section := range orderedSections { + titles = append(titles, section.title) + } + return titles +} + +func parseGitLog(raw []byte) ([]Commit, error) { + if len(bytes.TrimSpace(raw)) == 0 { + return nil, nil + } + + records := bytes.Split(raw, []byte{0x1e}) + commits := make([]Commit, 0, len(records)) + for _, record := range records { + record = bytes.Trim(record, "\n") + if len(bytes.TrimSpace(record)) == 0 { + continue + } + + fields := bytes.SplitN(record, []byte{0x1f}, 4) + if len(fields) != 4 { + return nil, fmt.Errorf("malformed commit log record: expected 4 fields, got %d", len(fields)) + } + + commit := Commit{ + Hash: strings.TrimSpace(string(fields[0])), + ShortHash: strings.TrimSpace(string(fields[1])), + Subject: strings.TrimSpace(string(fields[2])), + Body: strings.TrimSpace(string(fields[3])), + } + if commit.Hash == "" || commit.ShortHash == "" { + return nil, errors.New("malformed commit log record: missing commit hash") + } + commits = append(commits, commit) + } + return commits, nil +} + +func shouldSkipSubject(subject string) bool { + lower := strings.ToLower(strings.TrimSpace(subject)) + if lower == "" { + return false + } + for _, prefix := range []string{"fixup!", "squash!", "amend!"} { + if strings.HasPrefix(lower, prefix) { + return true + } + } + return strings.HasPrefix(lower, "merge ") +} + +func sectionForType(commitType string, conventional bool) (string, bool) { + if !conventional { + return SectionImprovements, false + } + + switch commitType { + case "feat", "feature": + return SectionFeatures, false + case "fix", "bugfix": + return SectionFixes, false + case "perf": + return SectionPerformance, false + case "docs", "doc": + return SectionDocs, false + case "refactor", "improve", "improvement", "enhance", "enhancement": + return SectionImprovements, false + case "build", "chore", "ci", "internal", "style", "test": + return SectionMaintenance, true + default: + return SectionImprovements, false + } +} + +func breakingNote(body string) string { + matches := breakingFooterRe.FindStringSubmatch(body) + if len(matches) < 2 { + return "" + } + return strings.TrimSpace(matches[1]) +} + +func runGit(ctx context.Context, repoDir string, args ...string) ([]byte, error) { + cmd := exec.CommandContext(ctx, "git", append([]string{"-C", repoDir}, args...)...) + var stderr bytes.Buffer + cmd.Stderr = &stderr + out, err := cmd.Output() + if err != nil { + msg := strings.TrimSpace(stderr.String()) + if msg == "" { + msg = err.Error() + } + return nil, errors.New(msg) + } + return out, nil +} diff --git a/internal/releasenotes/releasenotes_test.go b/internal/releasenotes/releasenotes_test.go new file mode 100644 index 00000000..9dab59a2 --- /dev/null +++ b/internal/releasenotes/releasenotes_test.go @@ -0,0 +1,167 @@ +package releasenotes + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestParseSubjectScopedConventionalCommit(t *testing.T) { + parsed := ParseSubject("feat(api): add release note endpoint") + + if !parsed.Conventional { + t.Fatal("expected conventional subject") + } + if parsed.Type != "feat" { + t.Fatalf("type = %q, want feat", parsed.Type) + } + if parsed.Scope != "api" { + t.Fatalf("scope = %q, want api", parsed.Scope) + } + if parsed.Description != "add release note endpoint" { + t.Fatalf("description = %q", parsed.Description) + } +} + +func TestClassifyUnknownSubjectAsImprovement(t *testing.T) { + item, section, ok := Classify(Commit{ + ShortHash: "abc1234", + Subject: "Polish release checklist", + }) + if !ok { + t.Fatal("expected commit to be included") + } + if section != SectionImprovements { + t.Fatalf("section = %q, want %q", section, SectionImprovements) + } + if item.Subject != "Polish release checklist" { + t.Fatalf("subject = %q", item.Subject) + } + if item.Internal { + t.Fatal("unknown subjects should not be treated as internal") + } +} + +func TestClassifyFiltersFixupSquashAndMergeSubjects(t *testing.T) { + subjects := []string{ + "fixup! feat: add release notes", + "squash! fix: repair output", + "Merge branch 'main'", + } + for _, subject := range subjects { + t.Run(subject, func(t *testing.T) { + _, _, ok := Classify(Commit{ShortHash: "abc1234", Subject: subject}) + if ok { + t.Fatalf("expected %q to be filtered", subject) + } + }) + } +} + +func TestClassifyBreakingChangeFooter(t *testing.T) { + item, section, ok := Classify(Commit{ + ShortHash: "abc1234", + Subject: "feat: replace config file format", + Body: "feat: replace config file format\n\nBREAKING CHANGE: old config files must be migrated", + }) + if !ok { + t.Fatal("expected commit to be included") + } + if section != SectionBreaking { + t.Fatalf("section = %q, want %q", section, SectionBreaking) + } + if !item.Breaking { + t.Fatal("expected breaking item") + } + if item.BreakingNote != "old config files must be migrated" { + t.Fatalf("breaking note = %q", item.BreakingNote) + } +} + +func TestBuildDraftFiltersInternalUnlessRequested(t *testing.T) { + commits := []Commit{ + {ShortHash: "aaa1111", Subject: "feat: add release notes"}, + {ShortHash: "bbb2222", Subject: "chore: tune release script"}, + } + + publicDraft := BuildDraft(commits, Draft{From: "v0.1.0", To: "HEAD"}, false) + if publicDraft.CommitCount != 1 { + t.Fatalf("public commit count = %d, want 1", publicDraft.CommitCount) + } + if got := sectionItems(publicDraft, SectionMaintenance); len(got) != 0 { + t.Fatalf("maintenance items = %d, want 0", len(got)) + } + + internalDraft := BuildDraft(commits, Draft{From: "v0.1.0", To: "HEAD"}, true) + if internalDraft.CommitCount != 2 { + t.Fatalf("internal commit count = %d, want 2", internalDraft.CommitCount) + } + if got := sectionItems(internalDraft, SectionMaintenance); len(got) != 1 { + t.Fatalf("maintenance items = %d, want 1", len(got)) + } +} + +func TestRenderMarkdownUsesStableSectionsAndShortSHAs(t *testing.T) { + draft := BuildDraft([]Commit{ + {ShortHash: "aaa1111", Subject: "feat(cli): add release notes"}, + {ShortHash: "bbb2222", Subject: "fix: handle empty range"}, + }, Draft{Version: "v0.2.0", Date: "2026-05-08", From: "v0.1.0", To: "HEAD"}, false) + + md := RenderMarkdown(draft) + for _, want := range []string{ + "## v0.2.0 - 2026-05-08", + "_Range: `v0.1.0..HEAD` (2 commits)_", + "### Features", + "- add release notes (aaa1111)", + "### Bug Fixes", + "- handle empty range (bbb2222)", + } { + if !strings.Contains(md, want) { + t.Fatalf("markdown missing %q:\n%s", want, md) + } + } + if strings.Contains(md, "feat(cli):") { + t.Fatalf("markdown should strip conventional prefix and scope:\n%s", md) + } +} + +func TestJSONDraftShape(t *testing.T) { + draft := BuildDraft([]Commit{ + {ShortHash: "aaa1111", Subject: "feat(cli): add release notes"}, + }, Draft{Version: "v0.2.0", From: "v0.1.0", To: "HEAD", Repository: "/repo"}, false) + + data, err := json.Marshal(draft) + if err != nil { + t.Fatal(err) + } + + var decoded Draft + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatal(err) + } + if decoded.Version != "v0.2.0" || decoded.From != "v0.1.0" || decoded.To != "HEAD" { + t.Fatalf("decoded draft has wrong range/version: %+v", decoded) + } + if decoded.CommitCount != 1 { + t.Fatalf("commit count = %d, want 1", decoded.CommitCount) + } + if len(decoded.Sections) != len(orderedSections) { + t.Fatalf("sections = %d, want %d", len(decoded.Sections), len(orderedSections)) + } + items := sectionItems(&decoded, SectionFeatures) + if len(items) != 1 { + t.Fatalf("feature items = %d, want 1", len(items)) + } + if items[0].SHA != "aaa1111" || items[0].Type != "feat" || items[0].Scope != "cli" { + t.Fatalf("unexpected item: %+v", items[0]) + } +} + +func sectionItems(draft *Draft, id string) []Item { + for _, section := range draft.Sections { + if section.ID == id { + return section.Items + } + } + return nil +} diff --git a/website/docs/command-reference.md b/website/docs/command-reference.md index b81278c3..21f4a5ea 100644 --- a/website/docs/command-reference.md +++ b/website/docs/command-reference.md @@ -157,6 +157,7 @@ cat docs/acceptance.md | td update td-a1b2 --append --acceptance-file - | `td monitor` | Live TUI dashboard | | `td undo` | Undo last action | | `td version` | Show version | +| `td release-notes [flags]` | Draft local Markdown or JSON release notes from git commits. Defaults `--from` to the latest `v*` tag before `--to`. Flags: `--from`, `--to`, `--version`, `--date`, `--json`, `--include-internal` | | `td export` | Export database | | `td import` | Import issues | | `td stats [subcommand]` | Usage statistics |