diff --git a/README.md b/README.md index db67f9f..5e5dd3e 100644 --- a/README.md +++ b/README.md @@ -216,7 +216,7 @@ Releases are automated via GoReleaser. Pushing a version tag triggers GitHub Act ```bash # Draft a changelog entry from the latest tag through HEAD -td release-notes --version v0.2.0 +td changelog --version v0.2.0 # Create and push an annotated tag (triggers automated release) make release VERSION=v0.2.0 @@ -422,7 +422,7 @@ Analytics are stored locally and help identify workflow patterns. Disable with ` | Reject | `td reject --reason "..."` | | Link files | `td link ` | | Check file changes | `td files ` | -| Draft release notes | `td release-notes --version v0.2.0` | +| Draft changelog entry | `td changelog --version v0.2.0` | | Undo last action | `td undo` | | New named session | `td session --new "feature-work"` | | Live dashboard | `td monitor` | diff --git a/cmd/changelog.go b/cmd/changelog.go new file mode 100644 index 0000000..5df9c69 --- /dev/null +++ b/cmd/changelog.go @@ -0,0 +1,88 @@ +package cmd + +import ( + "errors" + "fmt" + "regexp" + "strings" + "time" + + changelogpkg "github.com/marcus/td/internal/changelog" + gitutil "github.com/marcus/td/internal/git" + "github.com/spf13/cobra" +) + +var ( + changelogNow = time.Now + releaseVersionPattern = regexp.MustCompile(`^v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$`) +) + +var changelogCmd = &cobra.Command{ + Use: "changelog", + Short: "Draft a CHANGELOG.md entry from committed git history", + GroupID: "system", + Args: cobra.NoArgs, + Long: `Draft a paste-ready CHANGELOG.md entry from committed git history. + +By default, td uses the latest reachable semver tag through HEAD as the +baseline, filters out documentation/test/CI/chore commits, and prints a +markdown block you can review before updating CHANGELOG.md manually.`, + Example: ` td changelog --version v0.44.0 + td changelog --version v0.44.0 --date 2026-04-15 + td changelog --version v0.44.0 --from v0.43.0 --to HEAD + td changelog --version v0.44.0 --to v0.44.0 --include-meta`, + RunE: func(cmd *cobra.Command, args []string) error { + fromRef, _ := cmd.Flags().GetString("from") + toRef, _ := cmd.Flags().GetString("to") + versionLabel, _ := cmd.Flags().GetString("version") + dateValue, _ := cmd.Flags().GetString("date") + includeMeta, _ := cmd.Flags().GetBool("include-meta") + + versionLabel = strings.TrimSpace(versionLabel) + if versionLabel == "" { + return errors.New("--version is required") + } + if !releaseVersionPattern.MatchString(versionLabel) { + return fmt.Errorf("invalid --version %q: expected semver like v1.2.3", versionLabel) + } + + releaseDate := changelogNow() + if strings.TrimSpace(dateValue) != "" { + parsedDate, err := time.Parse("2006-01-02", dateValue) + if err != nil { + return fmt.Errorf("invalid --date %q: expected YYYY-MM-DD", dateValue) + } + releaseDate = parsedDate + } + + draft, err := changelogpkg.Generate(gitHistoryRepoDir(), changelogpkg.Options{ + FromRef: fromRef, + ToRef: toRef, + Version: versionLabel, + Date: releaseDate, + IncludeMeta: includeMeta, + }) + if err != nil { + switch { + case errors.Is(err, gitutil.ErrNotRepository): + return fmt.Errorf("changelog requires a git repository") + case errors.Is(err, gitutil.ErrNoSemverTag): + return fmt.Errorf("no reachable semver tag found; use --from to set the starting ref") + default: + return err + } + } + + _, err = fmt.Fprint(cmd.OutOrStdout(), draft.Markdown()) + return err + }, +} + +func init() { + rootCmd.AddCommand(changelogCmd) + changelogCmd.Flags().String("from", "", "Start the range at this git ref or tag") + changelogCmd.Flags().String("to", "HEAD", "End the range at this git ref") + changelogCmd.Flags().String("version", "", "Version label for the markdown header (required)") + changelogCmd.Flags().String("date", "", "Release date for the markdown header (YYYY-MM-DD, default: today)") + changelogCmd.Flags().Bool("include-meta", false, "Include documentation, test, CI, and chore commits") +} diff --git a/cmd/changelog_test.go b/cmd/changelog_test.go new file mode 100644 index 0000000..2d03e6d --- /dev/null +++ b/cmd/changelog_test.go @@ -0,0 +1,295 @@ +package cmd + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func saveAndRestoreChangelogState(t *testing.T, now time.Time) { + t.Helper() + + saveAndRestoreGlobals(t) + origBaseDirOverride := baseDirOverride + origNow := changelogNow + + changelogNow = func() time.Time { return now } + t.Cleanup(func() { + baseDirOverride = origBaseDirOverride + changelogNow = origNow + }) +} + +func runChangelogCommand(t *testing.T, dir string, flagPairs ...string) (string, error) { + t.Helper() + + baseDir := dir + baseDirOverride = &baseDir + + _ = changelogCmd.Flags().Set("from", "") + _ = changelogCmd.Flags().Set("to", "HEAD") + _ = changelogCmd.Flags().Set("version", "") + _ = changelogCmd.Flags().Set("date", "") + _ = changelogCmd.Flags().Set("include-meta", "false") + + for i := 0; i+1 < len(flagPairs); i += 2 { + if err := changelogCmd.Flags().Set(flagPairs[i], flagPairs[i+1]); err != nil { + t.Fatalf("failed to set --%s: %v", flagPairs[i], err) + } + } + + var output bytes.Buffer + changelogCmd.SetOut(&output) + + err := changelogCmd.RunE(changelogCmd, nil) + return output.String(), err +} + +func runChangelogCommandFromCWD(t *testing.T, cwd string, flagPairs ...string) (string, error) { + t.Helper() + + baseDirOverride = nil + + _ = changelogCmd.Flags().Set("from", "") + _ = changelogCmd.Flags().Set("to", "HEAD") + _ = changelogCmd.Flags().Set("version", "") + _ = changelogCmd.Flags().Set("date", "") + _ = changelogCmd.Flags().Set("include-meta", "false") + + for i := 0; i+1 < len(flagPairs); i += 2 { + if err := changelogCmd.Flags().Set(flagPairs[i], flagPairs[i+1]); err != nil { + t.Fatalf("failed to set --%s: %v", flagPairs[i], err) + } + } + + origWD, err := os.Getwd() + if err != nil { + t.Fatalf("getwd failed: %v", err) + } + if err := os.Chdir(cwd); err != nil { + t.Fatalf("chdir failed: %v", err) + } + t.Cleanup(func() { + _ = os.Chdir(origWD) + }) + + var output bytes.Buffer + changelogCmd.SetOut(&output) + + err = changelogCmd.RunE(changelogCmd, nil) + return output.String(), err +} + +func TestChangelogCommandFormatsMarkdownWithOrderedSections(t *testing.T) { + saveAndRestoreChangelogState(t, time.Date(2026, 4, 15, 9, 0, 0, 0, time.UTC)) + dir := initReleaseNotesRepo(t) + + tagReleaseNotesRepo(t, dir, "v0.1.0") + commitReleaseNotesFile(t, dir, "cmd/changelog.go", "package cmd\n", "feat: add changelog command") + commitReleaseNotesFile(t, dir, "internal/changelog/changelog.go", "package changelog\n", "fix(parser): handle tagged release range") + commitReleaseNotesFile(t, dir, "internal/changelog/render.go", "package changelog\n", "refactor: simplify changelog renderer") + commitReleaseNotesFile(t, dir, "docs/changelog.md", "# Changelog\n", "docs: document changelog workflow") + commitReleaseNotesFile(t, dir, "cmd/changelog_test.go", "package cmd\n", "test: cover changelog command") + + output, err := runChangelogCommand(t, dir, "version", "v0.2.0") + if err != nil { + t.Fatalf("changelogCmd.RunE returned error: %v", err) + } + + expected := `## [v0.2.0] - 2026-04-15 + +### Features +- Add changelog command + +### Bug Fixes +- Parser: handle tagged release range + +### Improvements +- Simplify changelog renderer +` + + if output != expected { + t.Fatalf("unexpected markdown output:\n%s", output) + } +} + +func TestChangelogCommandDefaultsToLatestTag(t *testing.T) { + saveAndRestoreChangelogState(t, time.Date(2026, 4, 15, 9, 0, 0, 0, time.UTC)) + dir := initReleaseNotesRepo(t) + + tagReleaseNotesRepo(t, dir, "v0.1.0") + commitReleaseNotesFile(t, dir, "feature.txt", "feature\n", "feat: add old feature") + tagReleaseNotesRepo(t, dir, "v0.2.0") + commitReleaseNotesFile(t, dir, "fix.txt", "fix\n", "fix: patch release") + + output, err := runChangelogCommand(t, dir, "version", "v0.2.1") + if err != nil { + t.Fatalf("changelogCmd.RunE returned error: %v", err) + } + + if strings.Contains(output, "Add old feature") { + t.Fatalf("expected output to use latest tag only, got:\n%s", output) + } + if !strings.Contains(output, "Patch release") { + t.Fatalf("expected output to include latest fix, got:\n%s", output) + } +} + +func TestChangelogCommandDefaultsFromPreviousTagWhenToIsReleaseTag(t *testing.T) { + saveAndRestoreChangelogState(t, time.Date(2026, 4, 15, 9, 0, 0, 0, time.UTC)) + dir := initReleaseNotesRepo(t) + + tagReleaseNotesRepo(t, dir, "v0.1.0") + commitReleaseNotesFile(t, dir, "feature.txt", "feature\n", "feat: add changelog command") + tagReleaseNotesRepo(t, dir, "v0.2.0") + commitReleaseNotesFile(t, dir, "fix.txt", "fix\n", "fix: patch release") + + output, err := runChangelogCommand(t, dir, "to", "v0.2.0", "version", "v0.2.0") + if err != nil { + t.Fatalf("changelogCmd.RunE returned error: %v", err) + } + + if !strings.Contains(output, "Add changelog command") { + t.Fatalf("expected output to include tagged release commit, got:\n%s", output) + } + if strings.Contains(output, "Patch release") { + t.Fatalf("expected output to stop at the requested release tag, got:\n%s", output) + } +} + +func TestChangelogCommandUsesCurrentWorktreeForRepoResolution(t *testing.T) { + saveAndRestoreChangelogState(t, time.Date(2026, 4, 15, 9, 0, 0, 0, time.UTC)) + dir := initReleaseNotesRepo(t) + + tagReleaseNotesRepo(t, dir, "v0.1.0") + + wtPath := filepath.Join(t.TempDir(), "wt") + runGit(t, dir, "worktree", "add", wtPath, "-b", "changelog-worktree") + commitReleaseNotesFile(t, wtPath, "feature.txt", "feature\n", "feat: add worktree feature") + + t.Setenv("TD_WORK_DIR", dir) + + output, err := runChangelogCommandFromCWD(t, wtPath, "version", "v0.2.0") + if err != nil { + t.Fatalf("changelogCmd.RunE returned error: %v", err) + } + + if !strings.Contains(output, "Add worktree feature") { + t.Fatalf("expected output to use cwd worktree history, got:\n%s", output) + } +} + +func TestChangelogCommandUsesWorkDirFlagForRepoResolution(t *testing.T) { + saveAndRestoreChangelogState(t, time.Date(2026, 4, 15, 9, 0, 0, 0, time.UTC)) + dir := initReleaseNotesRepo(t) + + tagReleaseNotesRepo(t, dir, "v0.1.0") + commitReleaseNotesFile(t, dir, "feature.txt", "feature\n", "feat: add flagged work-dir feature") + + workDirFlag = dir + + output, err := runChangelogCommandFromCWD(t, t.TempDir(), "version", "v0.2.0") + if err != nil { + t.Fatalf("changelogCmd.RunE returned error: %v", err) + } + + if !strings.Contains(output, "Add flagged work-dir feature") { + t.Fatalf("expected output to use --work-dir repo, got:\n%s", output) + } +} + +func TestChangelogCommandRejectsInvalidVersion(t *testing.T) { + saveAndRestoreChangelogState(t, time.Date(2026, 4, 15, 9, 0, 0, 0, time.UTC)) + dir := initReleaseNotesRepo(t) + + tagReleaseNotesRepo(t, dir, "v0.1.0") + commitReleaseNotesFile(t, dir, "feature.txt", "feature\n", "feat: add changelog command") + + _, err := runChangelogCommand(t, dir, "version", "1.2.3") + if err == nil || !strings.Contains(err.Error(), `invalid --version "1.2.3"`) { + t.Fatalf("expected invalid version error, got %v", err) + } +} + +func TestChangelogCommandRejectsInvalidDate(t *testing.T) { + saveAndRestoreChangelogState(t, time.Date(2026, 4, 15, 9, 0, 0, 0, time.UTC)) + dir := initReleaseNotesRepo(t) + + tagReleaseNotesRepo(t, dir, "v0.1.0") + commitReleaseNotesFile(t, dir, "feature.txt", "feature\n", "feat: add changelog command") + + _, err := runChangelogCommand(t, dir, "version", "v0.2.0", "date", "2026/04/15") + if err == nil || !strings.Contains(err.Error(), `invalid --date "2026/04/15"`) { + t.Fatalf("expected invalid date error, got %v", err) + } +} + +func TestChangelogCommandShowsEmptyRangeFallback(t *testing.T) { + saveAndRestoreChangelogState(t, time.Date(2026, 4, 15, 9, 0, 0, 0, time.UTC)) + dir := initReleaseNotesRepo(t) + + tagReleaseNotesRepo(t, dir, "v0.1.0") + + output, err := runChangelogCommand(t, dir, "from", "HEAD", "to", "HEAD", "version", "v0.2.0") + if err != nil { + t.Fatalf("changelogCmd.RunE returned error: %v", err) + } + + expected := `## [v0.2.0] - 2026-04-15 + +_No committed changes found between HEAD and HEAD._ +` + + if output != expected { + t.Fatalf("unexpected markdown output:\n%s", output) + } +} + +func TestChangelogCommandShowsNoEntryFallbackByDefault(t *testing.T) { + saveAndRestoreChangelogState(t, time.Date(2026, 4, 15, 9, 0, 0, 0, time.UTC)) + dir := initReleaseNotesRepo(t) + + tagReleaseNotesRepo(t, dir, "v0.1.0") + commitReleaseNotesFile(t, dir, "docs/changelog.md", "# Changelog\n", "docs: document changelog workflow") + commitReleaseNotesFile(t, dir, "cmd/changelog_test.go", "package cmd\n", "test: cover changelog command") + + output, err := runChangelogCommand(t, dir, "version", "v0.2.0") + if err != nil { + t.Fatalf("changelogCmd.RunE returned error: %v", err) + } + + if !strings.Contains(output, "No changelog-worthy changes found between v0.1.0 and HEAD") { + t.Fatalf("expected filtered-range message, got:\n%s", output) + } + if !strings.Contains(output, "--include-meta") { + t.Fatalf("expected include-meta hint, got:\n%s", output) + } +} + +func TestChangelogCommandIncludesMetaWhenRequested(t *testing.T) { + saveAndRestoreChangelogState(t, time.Date(2026, 4, 15, 9, 0, 0, 0, time.UTC)) + dir := initReleaseNotesRepo(t) + + tagReleaseNotesRepo(t, dir, "v0.1.0") + commitReleaseNotesFile(t, dir, "docs/changelog.md", "# Changelog\n", "docs: document changelog workflow") + commitReleaseNotesFile(t, dir, "cmd/changelog_test.go", "package cmd\n", "test: cover changelog command") + commitReleaseNotesFile(t, dir, ".github/workflows/release.yml", "name: release\n", "CI: fix release pipeline") + + output, err := runChangelogCommand(t, dir, "version", "v0.2.0", "include-meta", "true") + if err != nil { + t.Fatalf("changelogCmd.RunE returned error: %v", err) + } + + if !strings.Contains(output, "### Documentation") || !strings.Contains(output, "Document changelog workflow") { + t.Fatalf("expected documentation section in output, got:\n%s", output) + } + if !strings.Contains(output, "### Other Changes") { + t.Fatalf("expected other changes section in output, got:\n%s", output) + } + if !strings.Contains(output, "Cover changelog command") || !strings.Contains(output, "Fix release pipeline") { + t.Fatalf("expected meta entries in output, got:\n%s", output) + } +} diff --git a/cmd/release_notes.go b/cmd/release_notes.go index 337f1dc..83cb5b4 100644 --- a/cmd/release_notes.go +++ b/cmd/release_notes.go @@ -31,7 +31,7 @@ CHANGELOG.md manually.`, toRef, _ := cmd.Flags().GetString("to") versionLabel, _ := cmd.Flags().GetString("version") - draft, err := releasenotes.Generate(releaseNotesRepoDir(), releasenotes.Options{ + draft, err := releasenotes.Generate(gitHistoryRepoDir(), releasenotes.Options{ FromRef: fromRef, ToRef: toRef, Version: versionLabel, @@ -53,10 +53,10 @@ CHANGELOG.md manually.`, }, } -// releaseNotesRepoDir uses the active worktree instead of td's resolved +// gitHistoryRepoDir uses the active worktree instead of td's resolved // database root so repo-scoped git history is drafted from the branch the user // is actually on. -func releaseNotesRepoDir() string { +func gitHistoryRepoDir() string { if baseDirOverride != nil { return *baseDirOverride } diff --git a/docs/guides/releasing-new-version.md b/docs/guides/releasing-new-version.md index ccfc544..6b03e7c 100644 --- a/docs/guides/releasing-new-version.md +++ b/docs/guides/releasing-new-version.md @@ -38,7 +38,7 @@ git tag -l | sort -V | tail -1 Generate a starting draft from committed history: ```bash -td release-notes --version vX.Y.Z > /tmp/td-release-notes.md +td changelog --version vX.Y.Z > /tmp/td-changelog.md ``` If you need to override the default git range, pass `--from` and `--to`. @@ -46,9 +46,11 @@ For an already-tagged or retagged release, point `--to` at the tag and `td` will use the prior semver tag automatically: ```bash -td release-notes --to vX.Y.Z --version vX.Y.Z > /tmp/td-release-notes.md +td changelog --to vX.Y.Z --version vX.Y.Z > /tmp/td-changelog.md ``` +Use `--include-meta` if you want documentation, test, CI, or chore commits included in the draft. + Review the draft, edit it for clarity, and then add the final entry at the top of `CHANGELOG.md`: ```markdown @@ -148,11 +150,11 @@ Replace `X.Y.Z` with actual version: git status go test ./... -# Draft release notes -td release-notes --version vX.Y.Z > /tmp/td-release-notes.md +# Draft changelog entry +td changelog --version vX.Y.Z > /tmp/td-changelog.md # Update changelog -# (Review /tmp/td-release-notes.md, then edit CHANGELOG.md and add the final entry at top) +# (Review /tmp/td-changelog.md, then edit CHANGELOG.md and add the final entry at top) git add CHANGELOG.md git commit -m "docs: Update changelog for vX.Y.Z" @@ -171,7 +173,7 @@ brew upgrade td && td version - [ ] Tests pass (`go test ./...`) - [ ] Working tree clean -- [ ] `td release-notes --version vX.Y.Z` used to draft the changelog entry +- [ ] `td changelog --version vX.Y.Z` used to draft the changelog entry - [ ] CHANGELOG.md updated with new version entry - [ ] Changelog committed to git - [ ] Version number follows semver diff --git a/internal/changelog/changelog.go b/internal/changelog/changelog.go new file mode 100644 index 0000000..4ac4d26 --- /dev/null +++ b/internal/changelog/changelog.go @@ -0,0 +1,409 @@ +package changelog + +import ( + "fmt" + "regexp" + "strings" + "time" + "unicode" + "unicode/utf8" + + gitutil "github.com/marcus/td/internal/git" +) + +const ( + sectionFeatures = "Features" + sectionBugFixes = "Bug Fixes" + sectionImprovements = "Improvements" + sectionDocumentation = "Documentation" + sectionOtherChanges = "Other Changes" +) + +var ( + conventionalCommitPattern = regexp.MustCompile(`(?i)^(feat(?:ure)?|fix|bugfix|bug|docs?|doc|test(?:s)?|refactor|perf|chore|build|ci|style|revert)(?:\(([^)]+)\))?!?:\s*(.+)$`) + tdRefPrefixPattern = regexp.MustCompile(`^(?:(?:\[(?:td|task)-[^\]]+\])\s*)+`) + tdRefSuffixPattern = regexp.MustCompile(`\s+\((?:td|task)-[^)]+\)\.?$`) + taskPrefixPattern = regexp.MustCompile(`(?i)^task(?:\([^)]+\))?:\s*`) + releaseHousekeeping = []*regexp.Regexp{ + regexp.MustCompile(`(?i)^docs:\s+update changelog for v\d+\.\d+\.\d+`), + regexp.MustCompile(`(?i)^update changelog for v\d+\.\d+\.\d+`), + regexp.MustCompile(`(?i)^chore(?:\([^)]+\))?:\s+(?:prepare|cut|publish|release)\s+v\d+\.\d+\.\d+`), + regexp.MustCompile(`(?i)^release\s+v\d+\.\d+\.\d+`), + regexp.MustCompile(`(?i)^bump version to v\d+\.\d+\.\d+`), + } + sectionOrder = []string{ + sectionFeatures, + sectionBugFixes, + sectionImprovements, + sectionDocumentation, + sectionOtherChanges, + } +) + +// Options configures changelog generation. +type Options struct { + FromRef string + ToRef string + Version string + Date time.Time + IncludeMeta bool +} + +// Draft is paste-ready CHANGELOG content plus range metadata. +type Draft struct { + RepoRoot string + FromRef string + ToRef string + Version string + Date time.Time + IncludeMeta bool + SourceCommits int + Sections []Section +} + +// Section is one markdown section in the generated draft. +type Section struct { + Title string + Entries []string +} + +// Generate drafts a CHANGELOG entry from committed git history. +func Generate(repoDir string, opts Options) (*Draft, error) { + root, err := gitutil.GetRootDirFrom(repoDir) + if err != nil { + return nil, err + } + + rawToRef := strings.TrimSpace(opts.ToRef) + toRef := rawToRef + if toRef == "" { + toRef = "HEAD" + } + + fromRef := strings.TrimSpace(opts.FromRef) + if fromRef == "" { + if rawToRef != "" && !strings.EqualFold(toRef, "HEAD") { + tags, err := gitutil.GetSemverTagsPointingAt(root, toRef) + if err != nil { + return nil, err + } + if releaseTag := matchingSemverTagRef(rawToRef, tags); releaseTag != "" { + fromRef, err = gitutil.GetPreviousSemverTag(root, releaseTag) + } else { + fromRef, err = gitutil.GetLatestSemverTag(root, toRef) + } + } else { + fromRef, err = gitutil.GetLatestSemverTag(root, toRef) + } + if err != nil { + return nil, err + } + } + + commits, err := gitutil.ListCommitsInRange(root, fromRef, toRef) + if err != nil { + return nil, err + } + + date := opts.Date + if date.IsZero() { + date = time.Now() + } + + return buildDraft(commits, draftOptions{ + RepoRoot: root, + FromRef: fromRef, + ToRef: toRef, + Version: opts.Version, + Date: date, + IncludeMeta: opts.IncludeMeta, + }), nil +} + +// Markdown renders the draft as a CHANGELOG-ready markdown block. +func (d *Draft) Markdown() string { + var b strings.Builder + fmt.Fprintf(&b, "## [%s] - %s\n\n", d.Version, d.Date.Format("2006-01-02")) + + switch { + case d.SourceCommits == 0: + fmt.Fprintf(&b, "_No committed changes found between %s and %s._\n", d.FromRef, d.ToRef) + return b.String() + case len(d.Sections) == 0: + fmt.Fprintf(&b, "_No changelog-worthy changes found between %s and %s", d.FromRef, d.ToRef) + if !d.IncludeMeta { + b.WriteString(". Re-run with --include-meta to include documentation, test, CI, and chore commits") + } + b.WriteString("._\n") + return b.String() + } + + for _, section := range d.Sections { + fmt.Fprintf(&b, "### %s\n", section.Title) + for _, entry := range section.Entries { + fmt.Fprintf(&b, "- %s\n", entry) + } + b.WriteString("\n") + } + + return strings.TrimRight(b.String(), "\n") + "\n" +} + +type draftOptions struct { + RepoRoot string + FromRef string + ToRef string + Version string + Date time.Time + IncludeMeta bool +} + +func buildDraft(commits []gitutil.Commit, opts draftOptions) *Draft { + sectionsByTitle := make(map[string][]string) + for _, commit := range commits { + entry, ok := classifyCommit(commit, opts.IncludeMeta) + if !ok { + continue + } + sectionsByTitle[entry.section] = append(sectionsByTitle[entry.section], entry.text) + } + + sections := make([]Section, 0, len(sectionOrder)) + for _, title := range sectionOrder { + entries := sectionsByTitle[title] + if len(entries) == 0 { + continue + } + sections = append(sections, Section{ + Title: title, + Entries: entries, + }) + } + + version := strings.TrimSpace(opts.Version) + if version == "" { + version = "Unreleased" + } + + return &Draft{ + RepoRoot: opts.RepoRoot, + FromRef: opts.FromRef, + ToRef: opts.ToRef, + Version: version, + Date: opts.Date, + IncludeMeta: opts.IncludeMeta, + SourceCommits: len(commits), + Sections: sections, + } +} + +type entry struct { + section string + text string +} + +func classifyCommit(commit gitutil.Commit, includeMeta bool) (entry, bool) { + subject := cleanSubject(commit.Subject) + if subject == "" || isMergeCommit(subject) || isReleaseHousekeeping(subject) { + return entry{}, false + } + + if matches := conventionalCommitPattern.FindStringSubmatch(subject); len(matches) == 4 { + kind := strings.ToLower(strings.TrimSpace(matches[1])) + scope := strings.TrimSpace(matches[2]) + description := strings.TrimSpace(matches[3]) + return classifyConventional(kind, scope, description, includeMeta) + } + + if documentationOnly(commit.Files) { + if !includeMeta { + return entry{}, false + } + return entry{section: sectionDocumentation, text: cleanBulletText(subject)}, true + } + + if testsOnly(commit.Files) || ciOnly(commit.Files) { + if !includeMeta { + return entry{}, false + } + return entry{section: sectionOtherChanges, text: cleanBulletText(subject)}, true + } + + section, isMeta := classifyFreeform(subject) + if isMeta && !includeMeta { + return entry{}, false + } + + return entry{section: section, text: cleanBulletText(subject)}, true +} + +func classifyConventional(kind, scope, description string, includeMeta bool) (entry, bool) { + text := description + if scope != "" { + text = scope + ": " + description + } + text = cleanBulletText(text) + + switch kind { + case "feat", "feature": + return entry{section: sectionFeatures, text: text}, true + case "fix", "bugfix", "bug": + return entry{section: sectionBugFixes, text: text}, true + case "perf", "refactor", "build", "style": + return entry{section: sectionImprovements, text: text}, true + case "docs", "doc": + if !includeMeta { + return entry{}, false + } + return entry{section: sectionDocumentation, text: text}, true + case "test", "tests", "ci", "chore": + if !includeMeta { + return entry{}, false + } + return entry{section: sectionOtherChanges, text: text}, true + default: + return entry{section: sectionOtherChanges, text: text}, true + } +} + +func classifyFreeform(subject string) (string, bool) { + lower := strings.ToLower(subject) + candidates := []string{lower} + if _, tail, ok := strings.Cut(lower, ": "); ok && strings.TrimSpace(tail) != "" { + candidates = append(candidates, strings.TrimSpace(tail)) + } + + switch { + case hasLeadingVerb(candidates, "add", "introduce", "support", "enable", "implement", "show"): + return sectionFeatures, false + case hasLeadingVerb(candidates, "fix", "resolve", "correct", "prevent", "stabilize", "restore", "handle"): + return sectionBugFixes, false + case hasLeadingVerb(candidates, "clean up", "clarify", "align", "reduce", "increase", "improve", "simplify", "update", "upgrade", "optimize", "refine", "expose"): + return sectionImprovements, false + case hasLeadingVerb(candidates, "document", "docs", "doc", "readme", "documentation"): + return sectionDocumentation, true + case hasLeadingVerb(candidates, "test", "tests", "ci", "chore"): + return sectionOtherChanges, true + default: + return sectionOtherChanges, false + } +} + +func hasLeadingVerb(subjects []string, verbs ...string) bool { + for _, subject := range subjects { + for _, verb := range verbs { + if subject == verb || strings.HasPrefix(subject, verb+" ") || strings.HasPrefix(subject, verb+":") { + return true + } + } + } + return false +} + +func cleanSubject(subject string) string { + subject = strings.TrimSpace(subject) + subject = tdRefPrefixPattern.ReplaceAllString(subject, "") + subject = taskPrefixPattern.ReplaceAllString(subject, "") + subject = tdRefSuffixPattern.ReplaceAllString(subject, "") + subject = strings.Join(strings.Fields(subject), " ") + return subject +} + +func cleanBulletText(text string) string { + text = strings.TrimSpace(text) + text = strings.TrimSuffix(text, ".") + text = strings.Join(strings.Fields(text), " ") + if text == "" { + return text + } + + r, size := utf8.DecodeRuneInString(text) + if r == utf8.RuneError && size == 0 { + return text + } + if unicode.IsLower(r) { + return string(unicode.ToUpper(r)) + text[size:] + } + return text +} + +func isMergeCommit(subject string) bool { + return strings.HasPrefix(subject, "Merge ") +} + +func isReleaseHousekeeping(subject string) bool { + for _, pattern := range releaseHousekeeping { + if pattern.MatchString(subject) { + return true + } + } + return false +} + +func matchingSemverTagRef(rawRef string, tags []string) string { + ref := strings.TrimSpace(rawRef) + ref = strings.TrimPrefix(ref, "refs/tags/") + for _, tag := range tags { + if ref == tag { + return tag + } + } + return "" +} + +func documentationOnly(files []string) bool { + if len(files) == 0 { + return false + } + for _, file := range files { + if !isDocumentationFile(file) { + return false + } + } + return true +} + +func testsOnly(files []string) bool { + if len(files) == 0 { + return false + } + for _, file := range files { + if !isTestFile(file) { + return false + } + } + return true +} + +func ciOnly(files []string) bool { + if len(files) == 0 { + return false + } + for _, file := range files { + if !isCIFile(file) { + return false + } + } + return true +} + +func isDocumentationFile(path string) bool { + return strings.HasPrefix(path, "docs/") || + strings.HasPrefix(path, "website/docs/") || + strings.HasSuffix(path, ".md") || + strings.HasSuffix(path, ".mdx") || + path == "README" || + path == "README.md" || + path == "CHANGELOG.md" +} + +func isTestFile(path string) bool { + return strings.HasSuffix(path, "_test.go") || + strings.HasPrefix(path, "test/") || + strings.HasPrefix(path, "tests/") +} + +func isCIFile(path string) bool { + return strings.HasPrefix(path, ".github/workflows/") || + strings.HasPrefix(path, "ci/") +} diff --git a/internal/changelog/changelog_test.go b/internal/changelog/changelog_test.go new file mode 100644 index 0000000..1ba315c --- /dev/null +++ b/internal/changelog/changelog_test.go @@ -0,0 +1,219 @@ +package changelog + +import ( + "strings" + "testing" + "time" + + gitutil "github.com/marcus/td/internal/git" +) + +func TestBuildDraftClassifiesConventionalCommits(t *testing.T) { + draft := buildDraft([]gitutil.Commit{ + {Subject: "feat: add changelog command"}, + {Subject: "fix(parser): handle empty range"}, + {Subject: "refactor: simplify release-note rendering"}, + {Subject: "misc polish for release output"}, + }, draftOptions{ + RepoRoot: "/tmp/repo", + FromRef: "v0.1.0", + ToRef: "HEAD", + Version: "v0.2.0", + Date: time.Date(2026, 4, 15, 9, 0, 0, 0, time.UTC), + }) + + if len(draft.Sections) != 4 { + t.Fatalf("expected 4 sections, got %d", len(draft.Sections)) + } + + if draft.Sections[0].Title != sectionFeatures || draft.Sections[0].Entries[0] != "Add changelog command" { + t.Fatalf("unexpected features section: %+v", draft.Sections[0]) + } + if draft.Sections[1].Title != sectionBugFixes || draft.Sections[1].Entries[0] != "Parser: handle empty range" { + t.Fatalf("unexpected bug fixes section: %+v", draft.Sections[1]) + } + if draft.Sections[2].Title != sectionImprovements || draft.Sections[2].Entries[0] != "Simplify release-note rendering" { + t.Fatalf("unexpected improvements section: %+v", draft.Sections[2]) + } + if draft.Sections[3].Title != sectionOtherChanges || draft.Sections[3].Entries[0] != "Misc polish for release output" { + t.Fatalf("unexpected other changes section: %+v", draft.Sections[3]) + } +} + +func TestBuildDraftUsesFreeformVerbHeuristics(t *testing.T) { + draft := buildDraft([]gitutil.Commit{ + {Subject: "[td-79fac9] Show colored status line at top of td issue detail modal"}, + {Subject: "Fix closed_at timestamp to use current time on approve/close (#55)"}, + {Subject: "[td-377aae] Review commands: clarify stale concurrent transitions"}, + }, draftOptions{ + RepoRoot: "/tmp/repo", + FromRef: "v0.1.0", + ToRef: "HEAD", + Version: "v0.2.0", + Date: time.Date(2026, 4, 15, 9, 0, 0, 0, time.UTC), + }) + + if got := draft.Sections[0].Entries[0]; got != "Show colored status line at top of td issue detail modal" { + t.Fatalf("unexpected feature entry: %q", got) + } + if got := draft.Sections[1].Entries[0]; got != "Fix closed_at timestamp to use current time on approve/close (#55)" { + t.Fatalf("unexpected bug fix entry: %q", got) + } + if got := draft.Sections[2].Entries[0]; got != "Review commands: clarify stale concurrent transitions" { + t.Fatalf("unexpected improvement entry: %q", got) + } +} + +func TestBuildDraftExcludesMergeAndReleaseHousekeeping(t *testing.T) { + draft := buildDraft([]gitutil.Commit{ + {Subject: "Merge pull request #91 from marcus/dispatch/td-527bd4-0006"}, + {Subject: "docs: Update changelog for v0.43.0"}, + {Subject: "chore: release v0.44.0"}, + {Subject: "feat: add changelog command"}, + }, draftOptions{ + RepoRoot: "/tmp/repo", + FromRef: "v0.1.0", + ToRef: "HEAD", + Version: "v0.2.0", + Date: time.Date(2026, 4, 15, 9, 0, 0, 0, time.UTC), + }) + + if len(draft.Sections) != 1 { + t.Fatalf("expected 1 section, got %d", len(draft.Sections)) + } + if draft.Sections[0].Title != sectionFeatures || draft.Sections[0].Entries[0] != "Add changelog command" { + t.Fatalf("unexpected remaining section: %+v", draft.Sections[0]) + } +} + +func TestBuildDraftPreservesSectionOrder(t *testing.T) { + draft := buildDraft([]gitutil.Commit{ + {Subject: "chore: tidy release helper"}, + {Subject: "docs: explain changelog flow"}, + {Subject: "refactor: simplify parser"}, + {Subject: "fix: handle tagged releases"}, + {Subject: "feat: add changelog command"}, + }, draftOptions{ + RepoRoot: "/tmp/repo", + FromRef: "v0.1.0", + ToRef: "HEAD", + Version: "v0.2.0", + Date: time.Date(2026, 4, 15, 9, 0, 0, 0, time.UTC), + IncludeMeta: true, + }) + + got := []string{} + for _, section := range draft.Sections { + got = append(got, section.Title) + } + + want := []string{ + sectionFeatures, + sectionBugFixes, + sectionImprovements, + sectionDocumentation, + sectionOtherChanges, + } + + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("unexpected section order: got %v want %v", got, want) + } +} + +func TestBuildDraftCleansTaskPrefixesAndBulletText(t *testing.T) { + draft := buildDraft([]gitutil.Commit{ + {Subject: "[td-a7ff5e] task: feat: add rich text file input for issues (td-a7ff5e)."}, + }, draftOptions{ + RepoRoot: "/tmp/repo", + FromRef: "v0.1.0", + ToRef: "HEAD", + Version: "v0.2.0", + Date: time.Date(2026, 4, 15, 9, 0, 0, 0, time.UTC), + }) + + if got := draft.Sections[0].Entries[0]; got != "Add rich text file input for issues" { + t.Fatalf("unexpected cleaned bullet: %q", got) + } +} + +func TestBuildDraftFiltersMetaByDefault(t *testing.T) { + draft := buildDraft([]gitutil.Commit{ + {Subject: "docs: document release workflow"}, + {Subject: "test: cover tagged release range"}, + {Subject: "CI: fix pipeline", Files: []string{".github/workflows/release.yml"}}, + {Subject: "chore: tidy generated files"}, + }, draftOptions{ + RepoRoot: "/tmp/repo", + FromRef: "v0.1.0", + ToRef: "HEAD", + Version: "v0.2.0", + Date: time.Date(2026, 4, 15, 9, 0, 0, 0, time.UTC), + }) + + if len(draft.Sections) != 0 { + t.Fatalf("expected no visible sections, got %+v", draft.Sections) + } + + expected := "## [v0.2.0] - 2026-04-15\n\n_No changelog-worthy changes found between v0.1.0 and HEAD. Re-run with --include-meta to include documentation, test, CI, and chore commits._\n" + if got := draft.Markdown(); got != expected { + t.Fatalf("unexpected markdown:\n%s", got) + } +} + +func TestBuildDraftIncludesMetaWhenRequested(t *testing.T) { + draft := buildDraft([]gitutil.Commit{ + {Subject: "docs: explain changelog flow"}, + {Subject: "test: cover tagged release range"}, + {Subject: "CI: fix pipeline", Files: []string{".github/workflows/release.yml"}}, + {Subject: "chore: tidy generated files"}, + }, draftOptions{ + RepoRoot: "/tmp/repo", + FromRef: "v0.1.0", + ToRef: "HEAD", + Version: "v0.2.0", + Date: time.Date(2026, 4, 15, 9, 0, 0, 0, time.UTC), + IncludeMeta: true, + }) + + if len(draft.Sections) != 2 { + t.Fatalf("expected 2 sections, got %d", len(draft.Sections)) + } + if draft.Sections[0].Title != sectionDocumentation || draft.Sections[0].Entries[0] != "Explain changelog flow" { + t.Fatalf("unexpected documentation section: %+v", draft.Sections[0]) + } + if draft.Sections[1].Title != sectionOtherChanges { + t.Fatalf("unexpected other changes section: %+v", draft.Sections[1]) + } + if got := strings.Join(draft.Sections[1].Entries, "\n"); !strings.Contains(got, "Cover tagged release range") || !strings.Contains(got, "Fix pipeline") || !strings.Contains(got, "Tidy generated files") { + t.Fatalf("unexpected other changes entries: %s", got) + } +} + +func TestDraftMarkdownHandlesEmptyRange(t *testing.T) { + draft := buildDraft(nil, draftOptions{ + RepoRoot: "/tmp/repo", + FromRef: "v0.1.0", + ToRef: "HEAD", + Version: "v0.2.0", + Date: time.Date(2026, 4, 15, 9, 0, 0, 0, time.UTC), + }) + + expected := "## [v0.2.0] - 2026-04-15\n\n_No committed changes found between v0.1.0 and HEAD._\n" + if got := draft.Markdown(); got != expected { + t.Fatalf("unexpected markdown:\n%s", got) + } +} + +func TestMatchingSemverTagRefOnlyMatchesExplicitTagRefs(t *testing.T) { + tags := []string{"v0.2.1", "v0.2.0"} + + if got := matchingSemverTagRef("v0.2.1", tags); got != "v0.2.1" { + t.Fatalf("expected direct tag match, got %q", got) + } + if got := matchingSemverTagRef("refs/tags/v0.2.0", tags); got != "v0.2.0" { + t.Fatalf("expected refs/tags match, got %q", got) + } + if got := matchingSemverTagRef("deadbeef", tags); got != "" { + t.Fatalf("expected commit-like ref to not match, got %q", got) + } +} diff --git a/website/docs/command-reference.md b/website/docs/command-reference.md index 7260e3e..cc23567 100644 --- a/website/docs/command-reference.md +++ b/website/docs/command-reference.md @@ -140,6 +140,7 @@ cat docs/acceptance.md | td update td-a1b2 --append --acceptance-file - |---------|-------------| | `td init` | Initialize project | | `td monitor` | Live TUI dashboard | +| `td changelog [flags]` | Draft a paste-ready `CHANGELOG.md` entry from committed git history. Flags: `--version`, `--date`, `--from`, `--to`, `--include-meta` | | `td release-notes [flags]` | Draft markdown release notes from committed git history. Flags: `--from`, `--to`, `--version` | | `td undo` | Undo last action | | `td version` | Show version |