From 953c8fd6851a0dc4b552acbd850ea8ece6aad8d3 Mon Sep 17 00:00:00 2001 From: Review Bot Date: Sun, 19 Apr 2026 02:10:54 -0700 Subject: [PATCH 1/2] feat: add release notes draft command Nightshift-Task: release-notes Nightshift-Ref: https://github.com/marcus/nightshift --- README.md | 3 + cmd/release_notes.go | 102 +++++++ cmd/release_notes_test.go | 177 ++++++++++++ docs/guides/releasing-new-version.md | 14 + internal/git/git.go | 386 ++++++++++++++++++++++++++- internal/git/git_test.go | 118 ++++++++ internal/release/release.go | 239 +++++++++++++++++ internal/release/release_test.go | 76 ++++++ internal/release/render.go | 65 +++++ website/docs/command-reference.md | 1 + 10 files changed, 1168 insertions(+), 13 deletions(-) create mode 100644 cmd/release_notes.go create mode 100644 cmd/release_notes_test.go create mode 100644 internal/release/release.go create mode 100644 internal/release/release_test.go create mode 100644 internal/release/render.go diff --git a/README.md b/README.md index 684416ad..6f1b6c90 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 +# Draft notes from the latest tag before editing CHANGELOG.md +td release-notes --output markdown --include-files --include-stats + # Create and push an annotated tag (triggers automated release) make release VERSION=v0.2.0 diff --git a/cmd/release_notes.go b/cmd/release_notes.go new file mode 100644 index 00000000..a24b0267 --- /dev/null +++ b/cmd/release_notes.go @@ -0,0 +1,102 @@ +package cmd + +import ( + "errors" + "fmt" + "strings" + + "github.com/marcus/td/internal/git" + "github.com/marcus/td/internal/output" + "github.com/marcus/td/internal/release" + "github.com/spf13/cobra" +) + +var releaseNotesCmd = &cobra.Command{ + Use: "release-notes", + Short: "Draft release notes from git history", + Long: "Build a markdown-first release notes draft from commits since the latest tag, or an explicit git revision range.", + GroupID: "system", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + repo := git.NewRepo(getBaseDir()) + if !repo.IsRepo() { + err := fmt.Errorf("release notes require a git repository") + output.Error("%v", err) + return err + } + + from, _ := cmd.Flags().GetString("from") + to, _ := cmd.Flags().GetString("to") + rangeArg, _ := cmd.Flags().GetString("range") + outputMode, _ := cmd.Flags().GetString("output") + includeFiles, _ := cmd.Flags().GetBool("include-files") + includeStats, _ := cmd.Flags().GetBool("include-stats") + title, _ := cmd.Flags().GetString("title") + + revisionRange, err := repo.ResolveRevisionRange(from, to, rangeArg) + if err != nil { + if errors.Is(err, git.ErrNoTagsFound) { + err = fmt.Errorf("no tags found; use --from --to or --range to draft release notes without tags") + } + output.Error("%v", err) + return err + } + + commits, err := repo.ListCommits(revisionRange.Expr) + if err != nil { + output.Error("%v", err) + return err + } + if len(commits) == 0 { + err = fmt.Errorf("no commits found in range %s", revisionRange.Expr) + output.Error("%v", err) + return err + } + + stats, err := repo.GetDiffStats(revisionRange.Expr) + if err != nil { + output.Error("%v", err) + return err + } + + draft := release.Build(commits, stats, release.Options{ + Title: title, + RevisionRange: revisionRange.Expr, + From: revisionRange.From, + To: revisionRange.To, + IncludeFiles: includeFiles, + IncludeDiffStats: includeStats, + }) + markdown := release.RenderMarkdown(draft, includeFiles, includeStats) + + switch strings.ToLower(strings.TrimSpace(outputMode)) { + case "", "terminal": + rendered, renderErr := output.RenderMarkdown(markdown) + if renderErr != nil { + fmt.Print(markdown) + return nil + } + fmt.Println(rendered) + return nil + case "markdown": + fmt.Print(markdown) + return nil + default: + err := fmt.Errorf("invalid output mode %q (expected terminal or markdown)", outputMode) + output.Error("%v", err) + return err + } + }, +} + +func init() { + releaseNotesCmd.Flags().String("from", "", "starting revision (defaults to latest tag)") + releaseNotesCmd.Flags().String("to", "HEAD", "ending revision") + releaseNotesCmd.Flags().String("range", "", "explicit git revision range (for example v0.9.0..HEAD)") + releaseNotesCmd.Flags().String("output", "terminal", "output mode: terminal or markdown") + releaseNotesCmd.Flags().Bool("include-files", false, "include changed files under each entry") + releaseNotesCmd.Flags().Bool("include-stats", false, "include diff summary stats near the top") + releaseNotesCmd.Flags().String("title", "Release Notes Draft", "document title") + + rootCmd.AddCommand(releaseNotesCmd) +} diff --git a/cmd/release_notes_test.go b/cmd/release_notes_test.go new file mode 100644 index 00000000..237b7130 --- /dev/null +++ b/cmd/release_notes_test.go @@ -0,0 +1,177 @@ +package cmd + +import ( + "bytes" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func initReleaseNotesRepo(t *testing.T) string { + t.Helper() + + dir := t.TempDir() + for _, args := range [][]string{ + {"init"}, + {"config", "user.email", "test@test.com"}, + {"config", "user.name", "Test User"}, + } { + cmd := exec.Command("git", args...) + cmd.Dir = dir + if err := cmd.Run(); err != nil { + t.Fatalf("git %v failed: %v", args, err) + } + } + + writeAndCommitReleaseFile(t, dir, "README.md", "# Test\n", "chore: initial commit") + tagCmd := exec.Command("git", "tag", "v0.1.0") + tagCmd.Dir = dir + if err := tagCmd.Run(); err != nil { + t.Fatalf("git tag failed: %v", err) + } + + writeAndCommitReleaseFile(t, dir, "cmd/release_notes.go", "package cmd\n", "feat: add release notes command") + writeAndCommitReleaseFile(t, dir, "docs/release.md", "# Release\n", "docs: add release docs") + writeAndCommitReleaseFile(t, dir, "internal/release/release.go", "package release\n", "fix: handle empty release range") + + return dir +} + +func writeAndCommitReleaseFile(t *testing.T, dir, path, contents, message string) { + t.Helper() + + fullPath := filepath.Join(dir, path) + if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil { + t.Fatalf("mkdir %s: %v", path, err) + } + if err := os.WriteFile(fullPath, []byte(contents), 0644); err != nil { + t.Fatalf("write %s: %v", path, err) + } + + addCmd := exec.Command("git", "add", path) + addCmd.Dir = dir + if err := addCmd.Run(); err != nil { + t.Fatalf("git add %s: %v", path, err) + } + + commitCmd := exec.Command("git", "commit", "-m", message) + commitCmd.Dir = dir + if err := commitCmd.Run(); err != nil { + t.Fatalf("git commit %q: %v", message, err) + } +} + +func runReleaseNotesCommand(t *testing.T, dir string, args ...string) (string, error) { + t.Helper() + + saveAndRestoreGlobals(t) + baseDir := dir + baseDirOverride = &baseDir + _ = releaseNotesCmd.Flags().Set("from", "") + _ = releaseNotesCmd.Flags().Set("to", "HEAD") + _ = releaseNotesCmd.Flags().Set("range", "") + _ = releaseNotesCmd.Flags().Set("output", "markdown") + _ = releaseNotesCmd.Flags().Set("include-files", "false") + _ = releaseNotesCmd.Flags().Set("include-stats", "false") + _ = releaseNotesCmd.Flags().Set("title", "Release Notes Draft") + + var output bytes.Buffer + oldStdout := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe failed: %v", err) + } + os.Stdout = w + + runErr := releaseNotesCmd.RunE(releaseNotesCmd, args) + + _ = w.Close() + os.Stdout = oldStdout + _, _ = io.Copy(&output, r) + + return output.String(), runErr +} + +func TestReleaseNotesCommandOutputsMarkdownDraft(t *testing.T) { + dir := initReleaseNotesRepo(t) + + output, err := runReleaseNotesCommand(t, dir) + if err != nil { + t.Fatalf("RunE error: %v", err) + } + + for _, want := range []string{ + "# Release Notes Draft", + "## Features", + "- Add release notes command", + "## Bug Fixes", + "- Handle empty release range", + "## Documentation", + "- Add release docs", + } { + if !strings.Contains(output, want) { + t.Fatalf("output missing %q:\n%s", want, output) + } + } +} + +func TestReleaseNotesCommandIncludesFilesAndStats(t *testing.T) { + dir := initReleaseNotesRepo(t) + + saveAndRestoreGlobals(t) + baseDir := dir + baseDirOverride = &baseDir + _ = releaseNotesCmd.Flags().Set("include-files", "true") + _ = releaseNotesCmd.Flags().Set("include-stats", "true") + _ = releaseNotesCmd.Flags().Set("output", "markdown") + + var output bytes.Buffer + oldStdout := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe failed: %v", err) + } + os.Stdout = w + + runErr := releaseNotesCmd.RunE(releaseNotesCmd, nil) + + _ = w.Close() + os.Stdout = oldStdout + _, _ = io.Copy(&output, r) + + if runErr != nil { + t.Fatalf("RunE error: %v", runErr) + } + + got := output.String() + if !strings.Contains(got, "Files: `cmd/release_notes.go`") { + t.Fatalf("expected file list in output:\n%s", got) + } + if !strings.Contains(got, "files changed") { + t.Fatalf("expected diff stats in output:\n%s", got) + } +} + +func TestReleaseNotesCommandErrorsWithoutTags(t *testing.T) { + dir := t.TempDir() + for _, args := range [][]string{ + {"init"}, + {"config", "user.email", "test@test.com"}, + {"config", "user.name", "Test User"}, + } { + cmd := exec.Command("git", args...) + cmd.Dir = dir + if err := cmd.Run(); err != nil { + t.Fatalf("git %v failed: %v", args, err) + } + } + writeAndCommitReleaseFile(t, dir, "README.md", "# Test\n", "feat: initial release prep") + + _, err := runReleaseNotesCommand(t, dir) + if err == nil || !strings.Contains(err.Error(), "no tags found") { + t.Fatalf("expected no-tags error, got %v", err) + } +} diff --git a/docs/guides/releasing-new-version.md b/docs/guides/releasing-new-version.md index ca98e527..739a77f6 100644 --- a/docs/guides/releasing-new-version.md +++ b/docs/guides/releasing-new-version.md @@ -35,6 +35,16 @@ git tag -l | sort -V | tail -1 ### 2. Update CHANGELOG.md +Draft release notes from git history before editing the changelog: + +```bash +# Default: latest tag..HEAD rendered in the terminal +td release-notes + +# Raw markdown, plus file and diff context for maintainers +td release-notes --output markdown --include-files --include-stats > /tmp/release-notes.md +``` + Add entry at the top of `CHANGELOG.md`: ```markdown @@ -134,6 +144,9 @@ Replace `X.Y.Z` with actual version: git status go test ./... +# Draft release notes from latest tag..HEAD +td release-notes --output markdown --include-files --include-stats + # Update changelog # (Edit CHANGELOG.md, add entry at top) git add CHANGELOG.md @@ -154,6 +167,7 @@ brew upgrade td && td version - [ ] Tests pass (`go test ./...`) - [ ] Working tree clean +- [ ] Draft release notes reviewed (`td release-notes`) - [ ] CHANGELOG.md updated with new version entry - [ ] Changelog committed to git - [ ] Version number follows semver diff --git a/internal/git/git.go b/internal/git/git.go index f42c3d39..32abbbd7 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -4,10 +4,13 @@ package git import ( "bytes" + "errors" "fmt" "os/exec" + "path/filepath" "strconv" "strings" + "time" ) // State represents the current git state @@ -20,26 +23,67 @@ type State struct { DirtyFiles int } +// Commit represents a git commit plus the files it changed. +type Commit struct { + SHA string + ShortSHA string + Subject string + Body string + AuthorName string + AuthorEmail string + CommittedAt time.Time + Files []string +} + +// Repo provides git operations scoped to a specific working directory. +type Repo struct { + Dir string +} + +// RevisionRange represents a git revision span. +type RevisionRange struct { + From string + To string + Expr string +} + +var ErrNoTagsFound = errors.New("no tags found") + +// NewRepo returns a git helper rooted at dir. +func NewRepo(dir string) *Repo { + return &Repo{Dir: dir} +} + +// CurrentRepo returns a git helper for the current working directory. +func CurrentRepo() *Repo { + return &Repo{} +} + // GetState returns the current git state func GetState() (*State, error) { + return CurrentRepo().GetState() +} + +// GetState returns the current git state for the repo. +func (r *Repo) GetState() (*State, error) { state := &State{} // Get current commit SHA - sha, err := runGit("rev-parse", "HEAD") + sha, err := r.run("rev-parse", "HEAD") if err != nil { return nil, fmt.Errorf("not a git repository") } state.CommitSHA = strings.TrimSpace(sha) // Get current branch - branch, err := runGit("rev-parse", "--abbrev-ref", "HEAD") + branch, err := r.run("rev-parse", "--abbrev-ref", "HEAD") if err != nil { branch = "HEAD" } state.Branch = strings.TrimSpace(branch) // Get status - status, _ := runGit("status", "--porcelain") + status, _ := r.run("status", "--porcelain") lines := strings.Split(strings.TrimSpace(status), "\n") if status == "" || (len(lines) == 1 && lines[0] == "") { @@ -64,7 +108,12 @@ func GetState() (*State, error) { // GetCommitsSince returns the number of commits since a given SHA func GetCommitsSince(sha string) (int, error) { - output, err := runGit("rev-list", "--count", sha+"..HEAD") + return CurrentRepo().GetCommitsSince(sha) +} + +// GetCommitsSince returns the number of commits since a given SHA. +func (r *Repo) GetCommitsSince(sha string) (int, error) { + output, err := r.run("rev-list", "--count", sha+"..HEAD") if err != nil { return 0, err } @@ -77,12 +126,7 @@ func GetCommitsSince(sha string) (int, error) { // GetChangedFilesSince returns changed files since a given SHA func GetChangedFilesSince(sha string) ([]FileChange, error) { - output, err := runGit("diff", "--stat", sha+"..HEAD") - if err != nil { - return nil, err - } - - return parseStatOutput(output), nil + return CurrentRepo().GetChangedFilesSince(sha) } // FileChange represents changes to a file @@ -91,6 +135,9 @@ type FileChange struct { Additions int Deletions int IsNew bool + IsDeleted bool + IsRenamed bool + OldPath string } func parseStatOutput(output string) []FileChange { @@ -138,7 +185,39 @@ type DiffStats struct { // GetDiffStatsSince returns diff statistics since a given SHA func GetDiffStatsSince(sha string) (*DiffStats, error) { - output, err := runGit("diff", "--shortstat", sha+"..HEAD") + return CurrentRepo().GetDiffStatsSince(sha) +} + +// GetDiffStatsSince returns diff statistics since a given SHA. +func (r *Repo) GetDiffStatsSince(sha string) (*DiffStats, error) { + return r.GetDiffStats(sha + "..HEAD") +} + +// GetChangedFilesSince returns changed files since a given SHA. +func (r *Repo) GetChangedFilesSince(sha string) ([]FileChange, error) { + return r.GetChangedFiles(sha + "..HEAD") +} + +// GetChangedFiles returns changed files in a revision range. +func (r *Repo) GetChangedFiles(revisionRange string) ([]FileChange, error) { + numstatOutput, err := r.run("diff", "--find-renames", "--numstat", revisionRange) + if err != nil { + return nil, err + } + + statusOutput, err := r.run("diff", "--find-renames", "--name-status", revisionRange) + if err != nil { + return nil, err + } + + changes := parseNumstatOutput(numstatOutput) + applyNameStatus(changes, statusOutput) + return flattenFileChanges(changes), nil +} + +// GetDiffStats returns diff statistics for a revision range. +func (r *Repo) GetDiffStats(revisionRange string) (*DiffStats, error) { + output, err := r.run("diff", "--shortstat", revisionRange) if err != nil { return nil, err } @@ -179,21 +258,203 @@ func GetDiffStatsSince(sha string) (*DiffStats, error) { // IsRepo checks if we're in a git repository func IsRepo() bool { - _, err := runGit("rev-parse", "--git-dir") + return CurrentRepo().IsRepo() +} + +// IsRepo checks if the configured directory is in a git repository. +func (r *Repo) IsRepo() bool { + _, err := r.run("rev-parse", "--git-dir") return err == nil } // GetRootDir returns the git repository root directory func GetRootDir() (string, error) { - output, err := runGit("rev-parse", "--show-toplevel") + return CurrentRepo().GetRootDir() +} + +// GetRootDir returns the git repository root directory. +func (r *Repo) GetRootDir() (string, error) { + output, err := r.run("rev-parse", "--show-toplevel") if err != nil { return "", err } return strings.TrimSpace(output), nil } +// LatestTag returns the most recent reachable tag. +func LatestTag() (string, error) { + return CurrentRepo().LatestTag() +} + +// LatestTag returns the most recent reachable tag. +func (r *Repo) LatestTag() (string, error) { + output, err := r.run("describe", "--tags", "--abbrev=0") + if err != nil { + return "", ErrNoTagsFound + } + return strings.TrimSpace(output), nil +} + +// ResolveRevisionRange validates a revision range and returns its normalized form. +func ResolveRevisionRange(from, to, revisionRange string) (RevisionRange, error) { + return CurrentRepo().ResolveRevisionRange(from, to, revisionRange) +} + +// ResolveRevisionRange validates a revision range and returns its normalized form. +func (r *Repo) ResolveRevisionRange(from, to, revisionRange string) (RevisionRange, error) { + if strings.TrimSpace(revisionRange) != "" && (strings.TrimSpace(from) != "" || strings.TrimSpace(to) != "") { + return RevisionRange{}, fmt.Errorf("use either --range or --from/--to") + } + + if strings.TrimSpace(revisionRange) != "" { + if err := r.validateRevisionRange(revisionRange); err != nil { + return RevisionRange{}, err + } + return RevisionRange{Expr: revisionRange}, nil + } + + resolvedTo := strings.TrimSpace(to) + if resolvedTo == "" { + resolvedTo = "HEAD" + } + if _, err := r.ResolveRevision(resolvedTo); err != nil { + return RevisionRange{}, err + } + + resolvedFrom := strings.TrimSpace(from) + if resolvedFrom == "" { + tag, err := r.LatestTag() + if err != nil { + if errors.Is(err, ErrNoTagsFound) { + return RevisionRange{}, ErrNoTagsFound + } + return RevisionRange{}, err + } + resolvedFrom = tag + } else { + if _, err := r.ResolveRevision(resolvedFrom); err != nil { + return RevisionRange{}, err + } + } + + expr := resolvedFrom + ".." + resolvedTo + if err := r.validateRevisionRange(expr); err != nil { + return RevisionRange{}, err + } + return RevisionRange{From: resolvedFrom, To: resolvedTo, Expr: expr}, nil +} + +// ResolveRevision validates and normalizes a revision. +func (r *Repo) ResolveRevision(revision string) (string, error) { + revision = strings.TrimSpace(revision) + if revision == "" { + return "", fmt.Errorf("revision cannot be empty") + } + + output, err := r.run("rev-parse", "--verify", revision) + if err != nil { + return "", fmt.Errorf("invalid revision %q", revision) + } + return strings.TrimSpace(output), nil +} + +// ListCommits returns commits in a revision range, oldest first. +func ListCommits(revisionRange string) ([]Commit, error) { + return CurrentRepo().ListCommits(revisionRange) +} + +// ListCommits returns commits in a revision range, oldest first. +func (r *Repo) ListCommits(revisionRange string) ([]Commit, error) { + if err := r.validateRevisionRange(revisionRange); err != nil { + return nil, err + } + + output, err := r.run("log", "--reverse", "--format=%H%x1f%h%x1f%s%x1f%B%x1f%an%x1f%ae%x1f%cI%x1e", revisionRange) + if err != nil { + return nil, err + } + + var commits []Commit + for _, record := range strings.Split(output, "\x1e") { + record = strings.TrimSpace(record) + if record == "" { + continue + } + + fields := strings.Split(record, "\x1f") + if len(fields) < 7 { + continue + } + + committedAt, err := time.Parse(time.RFC3339, strings.TrimSpace(fields[6])) + if err != nil { + return nil, fmt.Errorf("parse commit time: %w", err) + } + + commit := Commit{ + SHA: strings.TrimSpace(fields[0]), + ShortSHA: strings.TrimSpace(fields[1]), + Subject: strings.TrimSpace(fields[2]), + Body: strings.TrimSpace(fields[3]), + AuthorName: strings.TrimSpace(fields[4]), + AuthorEmail: strings.TrimSpace(fields[5]), + CommittedAt: committedAt, + } + + files, err := r.commitFiles(commit.SHA) + if err != nil { + return nil, err + } + commit.Files = files + commits = append(commits, commit) + } + + return commits, nil +} + +func (r *Repo) commitFiles(sha string) ([]string, error) { + output, err := r.run("show", "--find-renames", "--pretty=format:", "--name-only", sha) + if err != nil { + return nil, err + } + + seen := map[string]struct{}{} + var files []string + for _, line := range strings.Split(output, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + if _, ok := seen[line]; ok { + continue + } + seen[line] = struct{}{} + files = append(files, line) + } + return files, nil +} + +func (r *Repo) validateRevisionRange(revisionRange string) error { + if strings.TrimSpace(revisionRange) == "" { + return fmt.Errorf("revision range cannot be empty") + } + if _, err := r.run("rev-list", "--count", revisionRange); err != nil { + return fmt.Errorf("invalid revision range %q", revisionRange) + } + return nil +} + func runGit(args ...string) (string, error) { + return CurrentRepo().run(args...) +} + +func (r *Repo) run(args ...string) (string, error) { cmd := exec.Command("git", args...) + if r != nil && strings.TrimSpace(r.Dir) != "" { + cmd.Dir = r.Dir + } else if cwd, err := filepath.Abs("."); err == nil { + cmd.Dir = cwd + } var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr @@ -205,3 +466,102 @@ func runGit(args ...string) (string, error) { return stdout.String(), nil } + +func parseNumstatOutput(output string) map[string]*FileChange { + changes := make(map[string]*FileChange) + + for _, line := range strings.Split(strings.TrimSpace(output), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + fields := strings.Split(line, "\t") + if len(fields) < 3 { + continue + } + + path := fields[2] + oldPath := "" + if len(fields) >= 4 { + oldPath = fields[2] + path = fields[3] + } + + change := &FileChange{ + Path: path, + OldPath: oldPath, + } + if n, err := strconv.Atoi(fields[0]); err == nil { + change.Additions = n + } + if n, err := strconv.Atoi(fields[1]); err == nil { + change.Deletions = n + } + changes[path] = change + } + + return changes +} + +func applyNameStatus(changes map[string]*FileChange, output string) { + for _, line := range strings.Split(strings.TrimSpace(output), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + fields := strings.Split(line, "\t") + if len(fields) < 2 { + continue + } + + status := fields[0] + path := fields[len(fields)-1] + change, ok := changes[path] + if !ok { + change = &FileChange{Path: path} + changes[path] = change + } + + switch { + case strings.HasPrefix(status, "A"): + change.IsNew = true + case strings.HasPrefix(status, "D"): + change.IsDeleted = true + case strings.HasPrefix(status, "R"): + change.IsRenamed = true + if len(fields) >= 3 { + change.OldPath = fields[1] + } + } + } +} + +func flattenFileChanges(changes map[string]*FileChange) []FileChange { + if len(changes) == 0 { + return nil + } + + paths := make([]string, 0, len(changes)) + for path := range changes { + paths = append(paths, path) + } + sortStrings(paths) + + result := make([]FileChange, 0, len(paths)) + for _, path := range paths { + result = append(result, *changes[path]) + } + return result +} + +func sortStrings(values []string) { + for i := 0; i < len(values); i++ { + for j := i + 1; j < len(values); j++ { + if values[j] < values[i] { + values[i], values[j] = values[j], values[i] + } + } + } +} diff --git a/internal/git/git_test.go b/internal/git/git_test.go index 183eea79..2a9e6b1b 100644 --- a/internal/git/git_test.go +++ b/internal/git/git_test.go @@ -1,9 +1,11 @@ package git import ( + "errors" "os" "os/exec" "path/filepath" + "strings" "testing" ) @@ -45,6 +47,31 @@ func runCmd(dir string, name string, args ...string) error { return cmd.Run() } +func commitFile(t *testing.T, dir, path, contents, message string) string { + t.Helper() + + if err := os.MkdirAll(filepath.Dir(filepath.Join(dir, path)), 0755); err != nil { + t.Fatalf("Failed to create parent dir for %s: %v", path, err) + } + if err := os.WriteFile(filepath.Join(dir, path), []byte(contents), 0644); err != nil { + t.Fatalf("Failed to write file %s: %v", path, err) + } + if err := runCmd(dir, "git", "add", path); err != nil { + t.Fatalf("Failed to git add %s: %v", path, err) + } + if err := runCmd(dir, "git", "commit", "-m", message); err != nil { + t.Fatalf("Failed to commit %s: %v", message, err) + } + + cmd := exec.Command("git", "rev-parse", "HEAD") + cmd.Dir = dir + output, err := cmd.Output() + if err != nil { + t.Fatalf("Failed to read HEAD SHA: %v", err) + } + return strings.TrimSpace(string(output)) +} + // TestParseStatOutputBasic tests parsing git diff --stat output func TestParseStatOutputBasic(t *testing.T) { output := ` file1.go | 10 ++++------ @@ -469,3 +496,94 @@ func TestStateBranchName(t *testing.T) { t.Logf("Branch name is %q (expected main/master/HEAD)", state.Branch) } } + +func TestRepoLatestTag(t *testing.T) { + dir := initTestRepo(t) + repo := NewRepo(dir) + + if err := runCmd(dir, "git", "tag", "v0.1.0"); err != nil { + t.Fatalf("Failed to create tag: %v", err) + } + + tag, err := repo.LatestTag() + if err != nil { + t.Fatalf("LatestTag failed: %v", err) + } + if tag != "v0.1.0" { + t.Fatalf("LatestTag = %q, want %q", tag, "v0.1.0") + } +} + +func TestRepoLatestTagNoTags(t *testing.T) { + dir := initTestRepo(t) + repo := NewRepo(dir) + + _, err := repo.LatestTag() + if !errors.Is(err, ErrNoTagsFound) { + t.Fatalf("LatestTag error = %v, want %v", err, ErrNoTagsFound) + } +} + +func TestRepoResolveRevisionRangeDefaultsToLatestTag(t *testing.T) { + dir := initTestRepo(t) + repo := NewRepo(dir) + + if err := runCmd(dir, "git", "tag", "v0.1.0"); err != nil { + t.Fatalf("Failed to create tag: %v", err) + } + commitFile(t, dir, "feature.txt", "hello\n", "feat: add release notes") + + rng, err := repo.ResolveRevisionRange("", "", "") + if err != nil { + t.Fatalf("ResolveRevisionRange failed: %v", err) + } + + if rng.From != "v0.1.0" { + t.Fatalf("From = %q, want %q", rng.From, "v0.1.0") + } + if rng.To != "HEAD" { + t.Fatalf("To = %q, want %q", rng.To, "HEAD") + } + if rng.Expr != "v0.1.0..HEAD" { + t.Fatalf("Expr = %q, want %q", rng.Expr, "v0.1.0..HEAD") + } +} + +func TestRepoResolveRevisionRangeRejectsMixedFlags(t *testing.T) { + dir := initTestRepo(t) + repo := NewRepo(dir) + + _, err := repo.ResolveRevisionRange("HEAD~1", "HEAD", "HEAD~1..HEAD") + if err == nil || !strings.Contains(err.Error(), "either --range or --from/--to") { + t.Fatalf("ResolveRevisionRange error = %v", err) + } +} + +func TestRepoListCommitsIncludesChangedFiles(t *testing.T) { + dir := initTestRepo(t) + repo := NewRepo(dir) + + if err := runCmd(dir, "git", "tag", "v0.1.0"); err != nil { + t.Fatalf("Failed to create tag: %v", err) + } + commitFile(t, dir, "docs/guide.md", "# Guide\n", "docs: add release guide") + commitFile(t, dir, "cmd/release_notes.go", "package cmd\n", "feat: add release notes command") + + commits, err := repo.ListCommits("v0.1.0..HEAD") + if err != nil { + t.Fatalf("ListCommits failed: %v", err) + } + + if len(commits) != 2 { + t.Fatalf("len(commits) = %d, want 2", len(commits)) + } + if commits[0].Subject != "docs: add release guide" { + t.Fatalf("first subject = %q", commits[0].Subject) + } + if len(commits[0].Files) != 1 || commits[0].Files[0] != "docs/guide.md" { + t.Fatalf("first commit files = %#v", commits[0].Files) + } + if len(commits[1].Files) != 1 || commits[1].Files[0] != "cmd/release_notes.go" { + t.Fatalf("second commit files = %#v", commits[1].Files) + } +} diff --git a/internal/release/release.go b/internal/release/release.go new file mode 100644 index 00000000..2509aa6c --- /dev/null +++ b/internal/release/release.go @@ -0,0 +1,239 @@ +package release + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/marcus/td/internal/git" +) + +const ( + SectionFeatures = "Features" + SectionBugFixes = "Bug Fixes" + SectionDocumentation = "Documentation" + SectionInternal = "Internal" + SectionUncategorized = "Uncategorized" +) + +type Options struct { + Title string + RevisionRange string + From string + To string + IncludeFiles bool + IncludeDiffStats bool + IncludeEmpty bool +} + +type Draft struct { + Title string + RevisionRange string + From string + To string + Sections []Section + DiffStats *git.DiffStats +} + +type Section struct { + Title string + Entries []Entry +} + +type Entry struct { + Commit git.Commit + Summary string + Files []string +} + +func Build(commits []git.Commit, stats *git.DiffStats, opts Options) Draft { + ordered := []string{ + SectionFeatures, + SectionBugFixes, + SectionDocumentation, + SectionInternal, + SectionUncategorized, + } + + grouped := map[string][]Entry{} + for _, commit := range commits { + section := classifyCommit(commit) + grouped[section] = append(grouped[section], Entry{ + Commit: commit, + Summary: humanizeSummary(commit.Subject), + Files: cleanFiles(commit.Files), + }) + } + + sections := make([]Section, 0, len(ordered)) + for _, title := range ordered { + entries := grouped[title] + if len(entries) == 0 && !opts.IncludeEmpty { + continue + } + sections = append(sections, Section{Title: title, Entries: entries}) + } + + title := strings.TrimSpace(opts.Title) + if title == "" { + title = "Release Notes Draft" + } + + return Draft{ + Title: title, + RevisionRange: opts.RevisionRange, + From: opts.From, + To: opts.To, + Sections: sections, + DiffStats: stats, + } +} + +func classifyCommit(commit git.Commit) string { + subject := strings.ToLower(strings.TrimSpace(commit.Subject)) + prefix := conventionalPrefix(subject) + + switch prefix { + case "feat", "feature": + return SectionFeatures + case "fix", "bugfix", "hotfix": + return SectionBugFixes + case "docs", "doc": + return SectionDocumentation + case "chore", "refactor", "test", "tests", "ci", "build", "perf", "style", "release": + return SectionInternal + } + + if hasAnyPrefix(subject, "fix ", "fix:", "bug ", "bug:", "resolve ", "resolved ") { + return SectionBugFixes + } + if hasAnyPrefix(subject, "add ", "adds ", "introduce ", "implement ", "support ", "create ") { + return SectionFeatures + } + if docsOnly(commit.Files) || strings.Contains(subject, "readme") || strings.Contains(subject, "changelog") { + return SectionDocumentation + } + if internalOnly(commit.Files) { + return SectionInternal + } + + return SectionUncategorized +} + +func conventionalPrefix(subject string) string { + if subject == "" { + return "" + } + end := strings.Index(subject, ":") + if end == -1 { + return "" + } + prefix := subject[:end] + if open := strings.Index(prefix, "("); open != -1 { + prefix = prefix[:open] + } + return strings.TrimSpace(prefix) +} + +func hasAnyPrefix(value string, prefixes ...string) bool { + for _, prefix := range prefixes { + if strings.HasPrefix(value, prefix) { + return true + } + } + return false +} + +func docsOnly(files []string) bool { + if len(files) == 0 { + return false + } + for _, file := range files { + if !isDocPath(file) { + return false + } + } + return true +} + +func internalOnly(files []string) bool { + if len(files) == 0 { + return false + } + for _, file := range files { + if isDocPath(file) { + continue + } + base := filepath.Base(file) + if strings.HasSuffix(base, "_test.go") { + continue + } + if strings.HasPrefix(file, ".github/") || strings.HasPrefix(file, "scripts/") || strings.HasPrefix(file, "deploy/") { + continue + } + return false + } + return true +} + +func isDocPath(path string) bool { + base := filepath.Base(path) + ext := strings.ToLower(filepath.Ext(path)) + return strings.HasPrefix(path, "docs/") || + strings.HasPrefix(path, "website/docs/") || + strings.EqualFold(base, "README.md") || + strings.EqualFold(base, "CHANGELOG.md") || + ext == ".md" +} + +func humanizeSummary(subject string) string { + summary := strings.TrimSpace(subject) + if summary == "" { + return "Untitled change" + } + + if prefix := conventionalPrefix(strings.ToLower(summary)); prefix != "" { + if idx := strings.Index(summary, ":"); idx != -1 && idx+1 < len(summary) { + summary = strings.TrimSpace(summary[idx+1:]) + } + } + + summary = strings.TrimSpace(strings.TrimSuffix(summary, ".")) + if summary == "" { + return "Untitled change" + } + + first := strings.ToUpper(summary[:1]) + if len(summary) == 1 { + return first + } + return first + summary[1:] +} + +func cleanFiles(files []string) []string { + if len(files) == 0 { + return nil + } + + seen := map[string]struct{}{} + cleaned := make([]string, 0, len(files)) + for _, file := range files { + file = strings.TrimSpace(file) + if file == "" { + continue + } + if _, ok := seen[file]; ok { + continue + } + seen[file] = struct{}{} + cleaned = append(cleaned, file) + } + return cleaned +} + +func (d Draft) SummaryLine() string { + if d.DiffStats == nil { + return "" + } + return fmt.Sprintf("%d files changed, %d insertions(+), %d deletions(-)", d.DiffStats.FilesChanged, d.DiffStats.Additions, d.DiffStats.Deletions) +} diff --git a/internal/release/release_test.go b/internal/release/release_test.go new file mode 100644 index 00000000..dbe306db --- /dev/null +++ b/internal/release/release_test.go @@ -0,0 +1,76 @@ +package release + +import ( + "strings" + "testing" + + "github.com/marcus/td/internal/git" +) + +func TestBuildClassifiesCommitsIntoSections(t *testing.T) { + commits := []git.Commit{ + {ShortSHA: "aaa1111", Subject: "feat: add release notes command", Files: []string{"cmd/release_notes.go"}}, + {ShortSHA: "bbb2222", Subject: "fix: handle empty ranges", Files: []string{"internal/release/release.go"}}, + {ShortSHA: "ccc3333", Subject: "docs: update release guide", Files: []string{"docs/guides/releasing-new-version.md"}}, + {ShortSHA: "ddd4444", Subject: "test: cover release notes command", Files: []string{"cmd/release_notes_test.go"}}, + {ShortSHA: "eee5555", Subject: "Rename helper", Files: []string{"internal/release/render.go"}}, + } + + draft := Build(commits, &git.DiffStats{FilesChanged: 5, Additions: 25, Deletions: 3}, Options{ + RevisionRange: "v0.1.0..HEAD", + }) + + if len(draft.Sections) != 5 { + t.Fatalf("len(sections) = %d, want 5", len(draft.Sections)) + } + + if got := draft.Sections[0].Title; got != SectionFeatures { + t.Fatalf("sections[0].Title = %q, want %q", got, SectionFeatures) + } + if got := draft.Sections[1].Title; got != SectionBugFixes { + t.Fatalf("sections[1].Title = %q, want %q", got, SectionBugFixes) + } + if got := draft.Sections[4].Title; got != SectionUncategorized { + t.Fatalf("sections[4].Title = %q, want %q", got, SectionUncategorized) + } +} + +func TestRenderMarkdownIncludesFilesAndStats(t *testing.T) { + draft := Build([]git.Commit{ + { + ShortSHA: "abc1234", + Subject: "feat: add release notes command", + Files: []string{"cmd/release_notes.go", "internal/release/release.go"}, + }, + }, &git.DiffStats{FilesChanged: 2, Additions: 18, Deletions: 4}, Options{ + Title: "v0.2.0 Draft", + RevisionRange: "v0.1.0..HEAD", + }) + + rendered := RenderMarkdown(draft, true, true) + for _, want := range []string{ + "# v0.2.0 Draft", + "_Range: `v0.1.0..HEAD`_", + "2 files changed, 18 insertions(+), 4 deletions(-)", + "## Features", + "- Add release notes command (`abc1234`)", + "Files: `cmd/release_notes.go`, `internal/release/release.go`", + } { + if !strings.Contains(rendered, want) { + t.Fatalf("rendered markdown missing %q:\n%s", want, rendered) + } + } +} + +func TestBuildOmitsEmptySectionsByDefault(t *testing.T) { + draft := Build([]git.Commit{ + {ShortSHA: "abc1234", Subject: "docs: update release guide", Files: []string{"README.md"}}, + }, nil, Options{}) + + if len(draft.Sections) != 1 { + t.Fatalf("len(sections) = %d, want 1", len(draft.Sections)) + } + if draft.Sections[0].Title != SectionDocumentation { + t.Fatalf("section title = %q, want %q", draft.Sections[0].Title, SectionDocumentation) + } +} diff --git a/internal/release/render.go b/internal/release/render.go new file mode 100644 index 00000000..5fea87b7 --- /dev/null +++ b/internal/release/render.go @@ -0,0 +1,65 @@ +package release + +import ( + "strings" +) + +func RenderMarkdown(draft Draft, includeFiles bool, includeStats bool) string { + var b strings.Builder + + b.WriteString("# ") + b.WriteString(draft.Title) + b.WriteString("\n\n") + + if draft.RevisionRange != "" { + b.WriteString("_Range: `") + b.WriteString(draft.RevisionRange) + b.WriteString("`_") + b.WriteString("\n\n") + } + + if includeStats { + if summary := draft.SummaryLine(); summary != "" { + b.WriteString(summary) + b.WriteString("\n\n") + } + } + + for _, section := range draft.Sections { + b.WriteString("## ") + b.WriteString(section.Title) + b.WriteString("\n") + if len(section.Entries) == 0 { + b.WriteString("- None\n\n") + continue + } + + for _, entry := range section.Entries { + b.WriteString("- ") + b.WriteString(entry.Summary) + if entry.Commit.ShortSHA != "" { + b.WriteString(" (`") + b.WriteString(entry.Commit.ShortSHA) + b.WriteString("`)") + } + b.WriteString("\n") + + if includeFiles && len(entry.Files) > 0 { + b.WriteString(" Files: ") + for i, file := range entry.Files { + if i > 0 { + b.WriteString(", ") + } + b.WriteString("`") + b.WriteString(file) + b.WriteString("`") + } + b.WriteString("\n") + } + } + + b.WriteString("\n") + } + + return strings.TrimSpace(b.String()) + "\n" +} diff --git a/website/docs/command-reference.md b/website/docs/command-reference.md index 28e9da1c..ac5e32d6 100644 --- a/website/docs/command-reference.md +++ b/website/docs/command-reference.md @@ -142,6 +142,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 release notes from git history. Flags: `--from`, `--to`, `--range`, `--output`, `--include-files`, `--include-stats`, `--title` | | `td export` | Export database | | `td import` | Import issues | | `td stats [subcommand]` | Usage statistics | From c379e9b8c58f88605172742491b930cb569f04d0 Mon Sep 17 00:00:00 2001 From: Review Bot Date: Sun, 19 Apr 2026 02:16:53 -0700 Subject: [PATCH 2/2] fix: allow explicit release-notes ranges Nightshift-Task: release-notes Nightshift-Ref: https://github.com/marcus/nightshift --- cmd/release_notes.go | 10 +++++ cmd/release_notes_test.go | 84 +++++++++++++++++++++++++++++++++++---- 2 files changed, 87 insertions(+), 7 deletions(-) diff --git a/cmd/release_notes.go b/cmd/release_notes.go index a24b0267..5850ce1e 100644 --- a/cmd/release_notes.go +++ b/cmd/release_notes.go @@ -33,6 +33,16 @@ var releaseNotesCmd = &cobra.Command{ includeStats, _ := cmd.Flags().GetBool("include-stats") title, _ := cmd.Flags().GetString("title") + if !cmd.Flags().Changed("from") { + from = "" + } + if !cmd.Flags().Changed("to") { + to = "" + } + if !cmd.Flags().Changed("range") { + rangeArg = "" + } + revisionRange, err := repo.ResolveRevisionRange(from, to, rangeArg) if err != nil { if errors.Is(err, git.ErrNoTagsFound) { diff --git a/cmd/release_notes_test.go b/cmd/release_notes_test.go index 237b7130..57c9b31a 100644 --- a/cmd/release_notes_test.go +++ b/cmd/release_notes_test.go @@ -70,13 +70,42 @@ func runReleaseNotesCommand(t *testing.T, dir string, args ...string) (string, e saveAndRestoreGlobals(t) baseDir := dir baseDirOverride = &baseDir - _ = releaseNotesCmd.Flags().Set("from", "") - _ = releaseNotesCmd.Flags().Set("to", "HEAD") - _ = releaseNotesCmd.Flags().Set("range", "") - _ = releaseNotesCmd.Flags().Set("output", "markdown") - _ = releaseNotesCmd.Flags().Set("include-files", "false") - _ = releaseNotesCmd.Flags().Set("include-stats", "false") - _ = releaseNotesCmd.Flags().Set("title", "Release Notes Draft") + resetReleaseNotesFlags(t) + + for i := 0; i < len(args); i++ { + arg := args[i] + if !strings.HasPrefix(arg, "-") { + continue + } + + name := strings.TrimLeft(arg, "-") + if eq := strings.Index(name, "="); eq != -1 { + flagName := name[:eq] + flagValue := name[eq+1:] + if err := releaseNotesCmd.Flags().Set(flagName, flagValue); err != nil { + t.Fatalf("set flag %s: %v", flagName, err) + } + continue + } + + flag := releaseNotesCmd.Flags().Lookup(name) + if flag == nil { + t.Fatalf("flag %s not found", name) + } + if flag.NoOptDefVal != "" { + if err := releaseNotesCmd.Flags().Set(name, flag.NoOptDefVal); err != nil { + t.Fatalf("set bool flag %s: %v", name, err) + } + continue + } + if i+1 >= len(args) { + t.Fatalf("missing value for flag %s", arg) + } + if err := releaseNotesCmd.Flags().Set(name, args[i+1]); err != nil { + t.Fatalf("set flag %s: %v", name, err) + } + i++ + } var output bytes.Buffer oldStdout := os.Stdout @@ -95,6 +124,31 @@ func runReleaseNotesCommand(t *testing.T, dir string, args ...string) (string, e return output.String(), runErr } +func resetReleaseNotesFlags(t *testing.T) { + t.Helper() + + defaults := map[string]string{ + "from": "", + "to": "HEAD", + "range": "", + "output": "markdown", + "include-files": "false", + "include-stats": "false", + "title": "Release Notes Draft", + } + + for name, value := range defaults { + flag := releaseNotesCmd.Flags().Lookup(name) + if flag == nil { + t.Fatalf("flag %s not found", name) + } + if err := flag.Value.Set(value); err != nil { + t.Fatalf("reset flag %s: %v", name, err) + } + flag.Changed = false + } +} + func TestReleaseNotesCommandOutputsMarkdownDraft(t *testing.T) { dir := initReleaseNotesRepo(t) @@ -175,3 +229,19 @@ func TestReleaseNotesCommandErrorsWithoutTags(t *testing.T) { t.Fatalf("expected no-tags error, got %v", err) } } + +func TestReleaseNotesCommandAcceptsExplicitRange(t *testing.T) { + dir := initReleaseNotesRepo(t) + + output, err := runReleaseNotesCommand(t, dir, "--range", "v0.1.0..HEAD") + if err != nil { + t.Fatalf("RunE error: %v", err) + } + + if !strings.Contains(output, "_Range: `v0.1.0..HEAD`_") { + t.Fatalf("expected explicit range in output:\n%s", output) + } + if !strings.Contains(output, "- Add release notes command") { + t.Fatalf("expected feature entry in output:\n%s", output) + } +}