diff --git a/README.md b/README.md index 684416ad..e96f2917 100644 --- a/README.md +++ b/README.md @@ -215,6 +215,9 @@ make fmt Releases are automated via GoReleaser. Pushing a version tag triggers GitHub Actions to build binaries and update the Homebrew formula. ```bash +# Preview release notes from commits since the nearest semver tag +td release-notes --version v0.2.0 --date 2026-04-29 + # Create and push an annotated tag (triggers automated release) make release VERSION=v0.2.0 @@ -422,6 +425,7 @@ Analytics are stored locally and help identify workflow patterns. Disable with ` | Undo last action | `td undo` | | New named session | `td session --new "feature-work"` | | Live dashboard | `td monitor` | +| Draft release notes | `td release-notes --version vX.Y.Z --date YYYY-MM-DD` | ### Boards diff --git a/cmd/release_notes.go b/cmd/release_notes.go new file mode 100644 index 00000000..379db52e --- /dev/null +++ b/cmd/release_notes.go @@ -0,0 +1,88 @@ +package cmd + +import ( + "fmt" + "os" + "strings" + "time" + + tdgit "github.com/marcus/td/internal/git" + "github.com/marcus/td/internal/releasenotes" + "github.com/spf13/cobra" +) + +var releaseNotesCmd = &cobra.Command{ + Use: "release-notes", + Short: "Draft markdown release notes from git commits", + GroupID: "system", + Args: cobra.NoArgs, + Example: ` td release-notes + td release-notes --from v0.4.0 --to HEAD + td release-notes --version v0.5.0 --date 2026-04-29`, + RunE: func(cmd *cobra.Command, args []string) error { + from, _ := cmd.Flags().GetString("from") + to, _ := cmd.Flags().GetString("to") + version, _ := cmd.Flags().GetString("version") + date, _ := cmd.Flags().GetString("date") + + if strings.TrimSpace(to) == "" { + return fmt.Errorf("--to cannot be empty") + } + if date != "" { + if version == "" { + return fmt.Errorf("--date requires --version") + } + if _, err := time.Parse("2006-01-02", date); err != nil { + return fmt.Errorf("--date must use YYYY-MM-DD format") + } + } + + repoDir := getBaseDir() + if repoDir == "" { + var err error + repoDir, err = os.Getwd() + if err != nil { + return err + } + } + + if _, err := tdgit.ResolveRef(repoDir, to); err != nil { + return err + } + + if strings.TrimSpace(from) == "" { + tag, err := tdgit.NearestSemverTag(repoDir, to) + if err != nil { + return fmt.Errorf("%w; pass --from to choose a release-note range explicitly", err) + } + from = tag + } else if _, err := tdgit.ResolveRef(repoDir, from); err != nil { + return err + } + + commits, err := tdgit.ListCommits(repoDir, from, to) + if err != nil { + return fmt.Errorf("failed to list commits for %s..%s: %w", from, to, err) + } + if len(commits) == 0 { + return fmt.Errorf("no commits found in %s..%s", from, to) + } + + if err := releasenotes.Render(cmd.OutOrStdout(), commits, releasenotes.Options{ + Version: version, + Date: date, + }); err != nil { + return fmt.Errorf("%w in %s..%s", err, from, to) + } + + return nil + }, +} + +func init() { + releaseNotesCmd.Flags().String("from", "", "start ref for the release-note range (defaults to nearest reachable semver tag)") + releaseNotesCmd.Flags().String("to", "HEAD", "end ref for the release-note range") + releaseNotesCmd.Flags().String("version", "", "version heading to render, such as v0.5.0") + releaseNotesCmd.Flags().String("date", "", "release date heading in YYYY-MM-DD format (requires --version)") + rootCmd.AddCommand(releaseNotesCmd) +} diff --git a/cmd/release_notes_test.go b/cmd/release_notes_test.go new file mode 100644 index 00000000..40a36b70 --- /dev/null +++ b/cmd/release_notes_test.go @@ -0,0 +1,187 @@ +package cmd + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestReleaseNotesDefaultRangeSelection(t *testing.T) { + repo := initReleaseNotesRepo(t) + gitCmd(t, repo, "tag", "v1.0.0") + commitReleaseNoteFile(t, repo, "feature.txt", "feat: add release notes") + + out, err := runReleaseNotesCommand(t, repo, nil) + if err != nil { + t.Fatalf("release-notes failed: %v", err) + } + if !strings.Contains(out, "### Features\n- Add release notes") { + t.Fatalf("expected feature markdown, got:\n%s", out) + } +} + +func TestReleaseNotesExplicitRange(t *testing.T) { + repo := initReleaseNotesRepo(t) + gitCmd(t, repo, "tag", "v1.0.0") + commitReleaseNoteFile(t, repo, "feature.txt", "feat: add release notes") + gitCmd(t, repo, "tag", "v1.1.0") + commitReleaseNoteFile(t, repo, "fix.txt", "fix: repair later bug") + + out, err := runReleaseNotesCommand(t, repo, map[string]string{ + "from": "v1.0.0", + "to": "v1.1.0", + }) + if err != nil { + t.Fatalf("release-notes failed: %v", err) + } + if !strings.Contains(out, "Add release notes") { + t.Fatalf("expected first range commit, got:\n%s", out) + } + if strings.Contains(out, "Repair later bug") { + t.Fatalf("did not expect commit after --to ref, got:\n%s", out) + } +} + +func TestReleaseNotesValidationFailures(t *testing.T) { + repo := initReleaseNotesRepo(t) + commitReleaseNoteFile(t, repo, "feature.txt", "feat: add release notes") + + tests := []struct { + name string + flags map[string]string + wantErr string + }{ + { + name: "date requires version", + flags: map[string]string{"date": "2026-04-29"}, + wantErr: "--date requires --version", + }, + { + name: "date format", + flags: map[string]string{"version": "v1.0.0", "date": "04-29-2026"}, + wantErr: "--date must use YYYY-MM-DD format", + }, + { + name: "missing default tag", + flags: nil, + wantErr: "pass --from ", + }, + { + name: "invalid from ref", + flags: map[string]string{"from": "missing-ref"}, + wantErr: "invalid git ref", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := runReleaseNotesCommand(t, repo, tt.flags) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("expected error containing %q, got %v", tt.wantErr, err) + } + }) + } +} + +func TestReleaseNotesStdoutMarkdownWithHeading(t *testing.T) { + repo := initReleaseNotesRepo(t) + gitCmd(t, repo, "tag", "v1.0.0") + commitReleaseNoteFile(t, repo, "fix.txt", "fix: repair output") + + out, err := runReleaseNotesCommand(t, repo, map[string]string{ + "version": "v1.1.0", + "date": "2026-04-29", + }) + if err != nil { + t.Fatalf("release-notes failed: %v", err) + } + + if !strings.HasPrefix(out, "## v1.1.0 - 2026-04-29\n\n") { + t.Fatalf("expected version/date heading, got:\n%s", out) + } + if !strings.Contains(out, "### Bug Fixes\n- Repair output") { + t.Fatalf("expected bug fix section, got:\n%s", out) + } +} + +func TestReleaseNotesNoRelevantCommits(t *testing.T) { + repo := initReleaseNotesRepo(t) + gitCmd(t, repo, "tag", "v1.0.0") + commitReleaseNoteFile(t, repo, "merge.txt", "fixup! feat: add hidden note") + + _, err := runReleaseNotesCommand(t, repo, nil) + if err == nil { + t.Fatal("expected no relevant commits error") + } + if !strings.Contains(err.Error(), "no release-note-worthy commits") { + t.Fatalf("unexpected error: %v", err) + } +} + +func runReleaseNotesCommand(t *testing.T, repo string, flags map[string]string) (string, error) { + t.Helper() + saveAndRestoreCommandFlags(t, releaseNotesCmd, "from", "to", "version", "date") + + oldBaseDirOverride := baseDirOverride + baseDirOverride = &repo + t.Cleanup(func() { + baseDirOverride = oldBaseDirOverride + }) + + var out bytes.Buffer + releaseNotesCmd.SetOut(&out) + t.Cleanup(func() { + releaseNotesCmd.SetOut(nil) + }) + + for name, value := range flags { + if err := releaseNotesCmd.Flags().Set(name, value); err != nil { + t.Fatalf("set flag %s=%s: %v", name, value, err) + } + } + + err := releaseNotesCmd.RunE(releaseNotesCmd, nil) + return out.String(), err +} + +func initReleaseNotesRepo(t *testing.T) string { + t.Helper() + dir := t.TempDir() + gitCmd(t, dir, "init") + gitCmd(t, dir, "config", "user.email", "test@example.com") + gitCmd(t, dir, "config", "user.name", "Test User") + if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("# test\n"), 0644); err != nil { + t.Fatalf("write README: %v", err) + } + gitCmd(t, dir, "add", ".") + gitCmd(t, dir, "commit", "-m", "Initial commit") + return dir +} + +func commitReleaseNoteFile(t *testing.T, repo, name, subject string) { + t.Helper() + path := filepath.Join(repo, name) + content := fmt.Sprintf("%s\n", subject) + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatalf("write %s: %v", name, err) + } + gitCmd(t, repo, "add", ".") + gitCmd(t, repo, "commit", "-m", subject) +} + +func gitCmd(t *testing.T, dir string, args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %s failed: %v\n%s", strings.Join(args, " "), err, output) + } +} diff --git a/docs/guides/releasing-new-version.md b/docs/guides/releasing-new-version.md index ca98e527..f9dedb56 100644 --- a/docs/guides/releasing-new-version.md +++ b/docs/guides/releasing-new-version.md @@ -33,23 +33,26 @@ Check current version: git tag -l | sort -V | tail -1 ``` -### 2. Update CHANGELOG.md +### 2. Draft and Review Release Notes -Add entry at the top of `CHANGELOG.md`: +Preview deterministic markdown from the local commit range. By default, +`td release-notes` uses commits from the nearest reachable semver tag through +`HEAD`. -```markdown -## [vX.Y.Z] - YYYY-MM-DD - -### Features -- New feature description +```bash +td release-notes --version vX.Y.Z --date YYYY-MM-DD +``` -### Bug Fixes -- Fix description +For an explicit range: -### Documentation -- Doc change description +```bash +td release-notes --from vX.Y.W --to HEAD --version vX.Y.Z --date YYYY-MM-DD ``` +Review the output, adjust wording as needed, then add the entry at the top of +`CHANGELOG.md`. The command intentionally uses local git history only; it does +not call GitHub or generate AI-authored prose. + Commit the changelog: ```bash git add CHANGELOG.md @@ -134,8 +137,9 @@ Replace `X.Y.Z` with actual version: git status go test ./... -# Update changelog -# (Edit CHANGELOG.md, add entry at top) +# Draft release notes and update changelog +td release-notes --version vX.Y.Z --date YYYY-MM-DD +# Review output, edit CHANGELOG.md, add entry at top git add CHANGELOG.md git commit -m "docs: Update changelog for vX.Y.Z" @@ -154,7 +158,8 @@ brew upgrade td && td version - [ ] Tests pass (`go test ./...`) - [ ] Working tree clean -- [ ] CHANGELOG.md updated with new version entry +- [ ] Release notes previewed with `td release-notes` +- [ ] CHANGELOG.md updated with reviewed release-note entry - [ ] Changelog committed to git - [ ] Version number follows semver - [ ] Commits pushed to main diff --git a/internal/git/git.go b/internal/git/git.go index f42c3d39..963a1e50 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -6,6 +6,7 @@ import ( "bytes" "fmt" "os/exec" + "regexp" "strconv" "strings" ) @@ -20,6 +21,15 @@ type State struct { DirtyFiles int } +// Commit represents a commit selected from local git history. +type Commit struct { + SHA string + ShortSHA string + Subject string + Body string + Date string +} + // GetState returns the current git state func GetState() (*State, error) { state := &State{} @@ -192,8 +202,107 @@ func GetRootDir() (string, error) { return strings.TrimSpace(output), nil } +// ResolveRef resolves ref to a full commit SHA in repoDir. +func ResolveRef(repoDir, ref string) (string, error) { + ref = strings.TrimSpace(ref) + if ref == "" { + return "", fmt.Errorf("ref is required") + } + output, err := runGitInDir(repoDir, "rev-parse", "--verify", ref+"^{commit}") + if err != nil { + return "", fmt.Errorf("invalid git ref %q: %w", ref, err) + } + return strings.TrimSpace(output), nil +} + +// NearestSemverTag returns the nearest reachable semver tag from ref. +func NearestSemverTag(repoDir, ref string) (string, error) { + toSHA, err := ResolveRef(repoDir, ref) + if err != nil { + return "", err + } + + output, err := runGitInDir(repoDir, "tag", "--merged", toSHA, "--list") + if err != nil { + return "", err + } + + tags := strings.Fields(output) + var bestTag string + bestDistance := -1 + for _, tag := range tags { + if !isSemverTag(tag) { + continue + } + countOutput, err := runGitInDir(repoDir, "rev-list", "--count", tag+".."+toSHA) + if err != nil { + continue + } + distance, err := strconv.Atoi(strings.TrimSpace(countOutput)) + if err != nil { + continue + } + if bestTag == "" || distance < bestDistance || distance == bestDistance && tag > bestTag { + bestTag = tag + bestDistance = distance + } + } + if bestTag == "" { + return "", fmt.Errorf("no reachable semver tag found from %s", ref) + } + return bestTag, nil +} + +// ListCommits returns commits in from..to in oldest-first order. +func ListCommits(repoDir, from, to string) ([]Commit, error) { + from = strings.TrimSpace(from) + to = strings.TrimSpace(to) + if from == "" || to == "" { + return nil, fmt.Errorf("from and to refs are required") + } + + format := "%H%x1f%h%x1f%ad%x1f%s%x1f%b%x1e" + output, err := runGitInDir(repoDir, "log", "--reverse", "--date=short", "--format="+format, from+".."+to) + if err != nil { + return nil, err + } + + var commits []Commit + for _, record := range strings.Split(output, "\x1e") { + record = strings.Trim(record, "\n") + if strings.TrimSpace(record) == "" { + continue + } + parts := strings.SplitN(record, "\x1f", 5) + if len(parts) != 5 { + continue + } + commits = append(commits, Commit{ + SHA: strings.TrimSpace(parts[0]), + ShortSHA: strings.TrimSpace(parts[1]), + Date: strings.TrimSpace(parts[2]), + Subject: strings.TrimSpace(parts[3]), + Body: strings.TrimSpace(parts[4]), + }) + } + return commits, nil +} + +func isSemverTag(tag string) bool { + return semverTagPattern.MatchString(tag) +} + +var semverTagPattern = regexp.MustCompile(`^v?[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$`) + func runGit(args ...string) (string, error) { + return runGitInDir("", args...) +} + +func runGitInDir(dir string, args ...string) (string, error) { cmd := exec.Command("git", args...) + if dir != "" { + cmd.Dir = dir + } var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr diff --git a/internal/git/git_test.go b/internal/git/git_test.go index 183eea79..338441ce 100644 --- a/internal/git/git_test.go +++ b/internal/git/git_test.go @@ -469,3 +469,108 @@ func TestStateBranchName(t *testing.T) { t.Logf("Branch name is %q (expected main/master/HEAD)", state.Branch) } } + +func TestResolveRefInRepoDir(t *testing.T) { + dir := initTestRepo(t) + + sha, err := ResolveRef(dir, "HEAD") + if err != nil { + t.Fatalf("ResolveRef failed: %v", err) + } + if len(sha) != 40 { + t.Fatalf("expected full SHA, got %q", sha) + } + + if _, err := ResolveRef(dir, "missing-ref"); err == nil { + t.Fatal("expected missing ref error") + } +} + +func TestNearestSemverTag(t *testing.T) { + dir := initTestRepo(t) + if err := runCmd(dir, "git", "tag", "v1.0.0"); err != nil { + t.Fatalf("Failed to tag v1.0.0: %v", err) + } + + commitFile(t, dir, "one.txt", "one", "feat: first change") + if err := runCmd(dir, "git", "tag", "not-a-version"); err != nil { + t.Fatalf("Failed to tag non-semver: %v", err) + } + + commitFile(t, dir, "two.txt", "two", "fix: second change") + if err := runCmd(dir, "git", "tag", "v1.1.0"); err != nil { + t.Fatalf("Failed to tag v1.1.0: %v", err) + } + + commitFile(t, dir, "three.txt", "three", "docs: third change") + + tag, err := NearestSemverTag(dir, "HEAD") + if err != nil { + t.Fatalf("NearestSemverTag failed: %v", err) + } + if tag != "v1.1.0" { + t.Fatalf("expected v1.1.0, got %q", tag) + } +} + +func TestNearestSemverTagNotFound(t *testing.T) { + dir := initTestRepo(t) + if err := runCmd(dir, "git", "tag", "latest"); err != nil { + t.Fatalf("Failed to tag latest: %v", err) + } + + if _, err := NearestSemverTag(dir, "HEAD"); err == nil { + t.Fatal("expected no reachable semver tag error") + } +} + +func TestListCommitsOldestFirstWithBody(t *testing.T) { + dir := initTestRepo(t) + if err := runCmd(dir, "git", "tag", "v1.0.0"); err != nil { + t.Fatalf("Failed to tag v1.0.0: %v", err) + } + + commitFile(t, dir, "feature.txt", "feature", "feat: add feature") + if err := os.WriteFile(filepath.Join(dir, "bug.txt"), []byte("bug"), 0644); err != nil { + t.Fatalf("Failed to write file: %v", err) + } + if err := runCmd(dir, "git", "add", "."); err != nil { + t.Fatalf("Failed to git add: %v", err) + } + if err := runCmd(dir, "git", "commit", "-m", "fix: repair bug", "-m", "Body details"); err != nil { + t.Fatalf("Failed to commit: %v", err) + } + + commits, err := ListCommits(dir, "v1.0.0", "HEAD") + if err != nil { + t.Fatalf("ListCommits failed: %v", err) + } + if len(commits) != 2 { + t.Fatalf("expected 2 commits, got %d", len(commits)) + } + if commits[0].Subject != "feat: add feature" { + t.Fatalf("expected oldest commit first, got %q", commits[0].Subject) + } + if commits[1].Subject != "fix: repair bug" { + t.Fatalf("expected second commit subject, got %q", commits[1].Subject) + } + if commits[1].Body != "Body details" { + t.Fatalf("expected body details, got %q", commits[1].Body) + } + if commits[0].SHA == "" || commits[0].ShortSHA == "" || commits[0].Date == "" { + t.Fatalf("expected commit metadata: %#v", commits[0]) + } +} + +func commitFile(t *testing.T, dir, name, content, subject string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0644); err != nil { + t.Fatalf("Failed to write file: %v", err) + } + if err := runCmd(dir, "git", "add", "."); err != nil { + t.Fatalf("Failed to git add: %v", err) + } + if err := runCmd(dir, "git", "commit", "-m", subject); err != nil { + t.Fatalf("Failed to commit: %v", err) + } +} diff --git a/internal/releasenotes/releasenotes.go b/internal/releasenotes/releasenotes.go new file mode 100644 index 00000000..e172e837 --- /dev/null +++ b/internal/releasenotes/releasenotes.go @@ -0,0 +1,210 @@ +// Package releasenotes turns local git commits into deterministic markdown. +package releasenotes + +import ( + "fmt" + "io" + "regexp" + "strings" + + "github.com/marcus/td/internal/git" +) + +const ( + SectionBreaking = "Breaking Changes" + SectionFeatures = "Features" + SectionBugFixes = "Bug Fixes" + SectionDocumentation = "Documentation" + SectionImprovements = "Improvements" +) + +var sectionOrder = []string{ + SectionBreaking, + SectionFeatures, + SectionBugFixes, + SectionDocumentation, + SectionImprovements, +} + +// Options controls the release-note heading. +type Options struct { + Version string + Date string +} + +// Entry is one release-note bullet. +type Entry struct { + Section string + Text string + ShortSHA string +} + +// Section is a rendered release-note section. +type Section struct { + Title string + Entries []Entry +} + +// Draft builds stable sections from commits. +func Draft(commits []git.Commit) []Section { + grouped := make(map[string][]Entry) + for _, commit := range commits { + entry, ok := entryFromCommit(commit) + if !ok { + continue + } + grouped[entry.Section] = append(grouped[entry.Section], entry) + } + + sections := make([]Section, 0, len(sectionOrder)) + for _, title := range sectionOrder { + entries := grouped[title] + if len(entries) == 0 { + continue + } + sections = append(sections, Section{Title: title, Entries: entries}) + } + return sections +} + +// Render writes release notes as markdown. +func Render(w io.Writer, commits []git.Commit, opts Options) error { + sections := Draft(commits) + if len(sections) == 0 { + return fmt.Errorf("no release-note-worthy commits") + } + + heading := "Release Notes" + if opts.Version != "" { + heading = opts.Version + if opts.Date != "" { + heading += " - " + opts.Date + } + } + if _, err := fmt.Fprintf(w, "## %s\n\n", heading); err != nil { + return err + } + + for i, section := range sections { + if i > 0 { + if _, err := fmt.Fprintln(w); err != nil { + return err + } + } + if _, err := fmt.Fprintf(w, "### %s\n", section.Title); err != nil { + return err + } + for _, entry := range section.Entries { + if entry.ShortSHA != "" { + if _, err := fmt.Fprintf(w, "- %s (%s)\n", entry.Text, entry.ShortSHA); err != nil { + return err + } + continue + } + if _, err := fmt.Fprintf(w, "- %s\n", entry.Text); err != nil { + return err + } + } + } + return nil +} + +func entryFromCommit(commit git.Commit) (Entry, bool) { + subject := strings.TrimSpace(commit.Subject) + if subject == "" || isNoisySubject(subject) { + return Entry{}, false + } + + parsed := parseSubject(subject) + if parsed.description == "" { + return Entry{}, false + } + + section := sectionForType(parsed.kind) + if parsed.breaking || hasBreakingFooter(commit.Body) { + section = SectionBreaking + } + + return Entry{ + Section: section, + Text: normalizeDescription(parsed.description), + ShortSHA: commit.ShortSHA, + }, true +} + +func isNoisySubject(subject string) bool { + lower := strings.ToLower(strings.TrimSpace(subject)) + return strings.HasPrefix(lower, "merge ") || + strings.HasPrefix(lower, "fixup!") || + strings.HasPrefix(lower, "squash!") +} + +type parsedSubject struct { + kind string + description string + breaking bool +} + +var conventionalPattern = regexp.MustCompile(`^([A-Za-z]+)(?:\([^)]+\))?(!)?:\s+(.+)$`) + +func parseSubject(subject string) parsedSubject { + match := conventionalPattern.FindStringSubmatch(subject) + if len(match) == 4 { + return parsedSubject{ + kind: strings.ToLower(match[1]), + description: match[3], + breaking: match[2] == "!", + } + } + + return parsedSubject{ + kind: inferLooseType(subject), + description: subject, + } +} + +func inferLooseType(subject string) string { + lower := strings.ToLower(subject) + switch { + case strings.HasPrefix(lower, "fix ") || strings.HasPrefix(lower, "repair ") || strings.HasPrefix(lower, "resolve "): + return "fix" + case strings.HasPrefix(lower, "add ") || strings.HasPrefix(lower, "introduce ") || strings.HasPrefix(lower, "create "): + return "feat" + case strings.HasPrefix(lower, "document ") || strings.HasPrefix(lower, "docs ") || strings.HasPrefix(lower, "update docs"): + return "docs" + default: + return "chore" + } +} + +func sectionForType(kind string) string { + switch kind { + case "feat", "feature": + return SectionFeatures + case "fix", "bugfix": + return SectionBugFixes + case "docs", "doc": + return SectionDocumentation + default: + return SectionImprovements + } +} + +func normalizeDescription(description string) string { + description = strings.TrimSpace(description) + description = strings.TrimSuffix(description, ".") + if description == "" { + return description + } + return strings.ToUpper(description[:1]) + description[1:] +} + +func hasBreakingFooter(body string) bool { + for _, line := range strings.Split(body, "\n") { + upper := strings.ToUpper(strings.TrimSpace(line)) + if strings.HasPrefix(upper, "BREAKING CHANGE:") || strings.HasPrefix(upper, "BREAKING-CHANGE:") { + return true + } + } + return false +} diff --git a/internal/releasenotes/releasenotes_test.go b/internal/releasenotes/releasenotes_test.go new file mode 100644 index 00000000..28c99afb --- /dev/null +++ b/internal/releasenotes/releasenotes_test.go @@ -0,0 +1,160 @@ +package releasenotes + +import ( + "bytes" + "strings" + "testing" + + "github.com/marcus/td/internal/git" +) + +func TestRenderConventionalCommits(t *testing.T) { + commits := []git.Commit{ + commit("a1b2c3d", "feat(cli): add release notes"), + commit("b2c3d4e", "fix: repair default range"), + commit("c3d4e5f", "docs: update release guide"), + commit("d4e5f6a", "refactor: simplify command setup"), + } + + got := renderString(t, commits, Options{}) + want := `## Release Notes + +### Features +- Add release notes (a1b2c3d) + +### Bug Fixes +- Repair default range (b2c3d4e) + +### Documentation +- Update release guide (c3d4e5f) + +### Improvements +- Simplify command setup (d4e5f6a) +` + if got != want { + t.Fatalf("unexpected markdown:\n%s", got) + } +} + +func TestRenderLooseCommitSubjects(t *testing.T) { + commits := []git.Commit{ + commit("a1b2c3d", "Add parser helper"), + commit("b2c3d4e", "Fix empty output"), + commit("c3d4e5f", "Document release workflow"), + commit("d4e5f6a", "Tidy tests"), + } + + got := renderString(t, commits, Options{}) + for _, want := range []string{ + "### Features\n- Add parser helper", + "### Bug Fixes\n- Fix empty output", + "### Documentation\n- Document release workflow", + "### Improvements\n- Tidy tests", + } { + if !strings.Contains(got, want) { + t.Fatalf("expected %q in:\n%s", want, got) + } + } +} + +func TestRenderBreakingChangeMarkers(t *testing.T) { + commits := []git.Commit{ + commit("a1b2c3d", "feat!: remove old config"), + {ShortSHA: "b2c3d4e", Subject: "fix: change storage", Body: "BREAKING CHANGE: database is migrated"}, + } + + got := renderString(t, commits, Options{}) + want := `## Release Notes + +### Breaking Changes +- Remove old config (a1b2c3d) +- Change storage (b2c3d4e) +` + if got != want { + t.Fatalf("unexpected markdown:\n%s", got) + } +} + +func TestDraftFiltersNoisyCommits(t *testing.T) { + commits := []git.Commit{ + commit("a1b2c3d", "Merge pull request #1 from branch"), + commit("b2c3d4e", "fixup! feat: add release notes"), + commit("c3d4e5f", "squash! fix: repair output"), + commit("d4e5f6a", "feat: keep this"), + } + + sections := Draft(commits) + if len(sections) != 1 { + t.Fatalf("expected 1 section, got %d", len(sections)) + } + if got := sections[0].Entries[0].Text; got != "Keep this" { + t.Fatalf("expected only non-noisy commit, got %q", got) + } +} + +func TestRenderEmptyRelevantRange(t *testing.T) { + var out bytes.Buffer + err := Render(&out, []git.Commit{ + commit("a1b2c3d", "Merge branch main"), + commit("b2c3d4e", "fixup! typo"), + }, Options{}) + if err == nil { + t.Fatal("expected empty relevant range error") + } + if !strings.Contains(err.Error(), "no release-note-worthy commits") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestRenderVersionAndDateHeading(t *testing.T) { + got := renderString(t, []git.Commit{commit("a1b2c3d", "feat: add notes")}, Options{ + Version: "v1.2.3", + Date: "2026-04-29", + }) + if !strings.HasPrefix(got, "## v1.2.3 - 2026-04-29\n\n") { + t.Fatalf("unexpected heading:\n%s", got) + } +} + +func TestDraftSectionOrderIsDeterministic(t *testing.T) { + commits := []git.Commit{ + commit("d4e5f6a", "chore: improve tests"), + commit("c3d4e5f", "docs: update docs"), + commit("b2c3d4e", "fix: repair bug"), + commit("a1b2c3d", "feat: add feature"), + commit("e5f6a7b", "feat!: break config"), + } + + got := renderString(t, commits, Options{}) + wantOrder := []string{ + "### Breaking Changes", + "### Features", + "### Bug Fixes", + "### Documentation", + "### Improvements", + } + last := -1 + for _, marker := range wantOrder { + idx := strings.Index(got, marker) + if idx == -1 { + t.Fatalf("missing marker %q in:\n%s", marker, got) + } + if idx <= last { + t.Fatalf("marker %q out of order in:\n%s", marker, got) + } + last = idx + } +} + +func renderString(t *testing.T, commits []git.Commit, opts Options) string { + t.Helper() + var out bytes.Buffer + if err := Render(&out, commits, opts); err != nil { + t.Fatalf("Render failed: %v", err) + } + return out.String() +} + +func commit(shortSHA, subject string) git.Commit { + return git.Commit{ShortSHA: shortSHA, Subject: subject} +} diff --git a/website/docs/command-reference.md b/website/docs/command-reference.md index b81278c3..6876aa9c 100644 --- a/website/docs/command-reference.md +++ b/website/docs/command-reference.md @@ -155,6 +155,7 @@ cat docs/acceptance.md | td update td-a1b2 --append --acceptance-file - |---------|-------------| | `td init` | Initialize project | | `td monitor` | Live TUI dashboard | +| `td release-notes [flags]` | Draft markdown release notes from git history. Flags: `--from`, `--to`, `--version`, `--date` | | `td undo` | Undo last action | | `td version` | Show version | | `td export` | Export database |