diff --git a/cmd/nightshift/commands/releasenotes.go b/cmd/nightshift/commands/releasenotes.go new file mode 100644 index 0000000..872268a --- /dev/null +++ b/cmd/nightshift/commands/releasenotes.go @@ -0,0 +1,146 @@ +package commands + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/marcus/nightshift/internal/analysis" + "github.com/marcus/nightshift/internal/releasenotes" + "github.com/spf13/cobra" +) + +var releaseNotesCmd = &cobra.Command{ + Use: "release-notes [path]", + Short: "Draft release notes from git history", + Long: `Generate release notes by analyzing git history between tags. + +Commits are automatically classified using conventional commit prefixes +(feat, fix, docs, etc.) and grouped into sections. Non-conventional commits +are classified by keyword inference. + +By default, generates notes for the latest tag compared to the previous tag. +Use --tag and --prev-tag to specify a custom range. + +Examples: + nightshift release-notes # Latest tag vs previous tag + nightshift release-notes --tag v1.0.0 # Notes for v1.0.0 + nightshift release-notes --tag HEAD # Unreleased changes since last tag + nightshift release-notes --tag v2.0 --prev-tag v1.0 # Custom range + nightshift release-notes --flat # Flat list, no grouping + nightshift release-notes --json # Structured JSON output`, + RunE: func(cmd *cobra.Command, args []string) error { + path, _ := cmd.Flags().GetString("path") + if path == "" && len(args) > 0 { + path = args[0] + } + if path == "" { + var err error + path, err = os.Getwd() + if err != nil { + return fmt.Errorf("getting current directory: %w", err) + } + } + + tag, _ := cmd.Flags().GetString("tag") + prevTag, _ := cmd.Flags().GetString("prev-tag") + flat, _ := cmd.Flags().GetBool("flat") + jsonOutput, _ := cmd.Flags().GetBool("json") + noHashes, _ := cmd.Flags().GetBool("no-hashes") + authors, _ := cmd.Flags().GetBool("authors") + + return runReleaseNotes(path, tag, prevTag, flat, jsonOutput, noHashes, authors) + }, +} + +func init() { + releaseNotesCmd.Flags().StringP("path", "p", "", "Repository path") + releaseNotesCmd.Flags().String("tag", "", "Tag to generate notes for (default: latest tag)") + releaseNotesCmd.Flags().String("prev-tag", "", "Previous tag to compare against (default: auto-detect)") + releaseNotesCmd.Flags().Bool("flat", false, "Flat list instead of grouped by category") + releaseNotesCmd.Flags().Bool("json", false, "Output as JSON") + releaseNotesCmd.Flags().Bool("no-hashes", false, "Omit commit hashes from output") + releaseNotesCmd.Flags().Bool("authors", false, "Include author names") + rootCmd.AddCommand(releaseNotesCmd) +} + +func runReleaseNotes(path, tag, prevTag string, flat, jsonOutput, noHashes, authors bool) error { + absPath, err := filepath.Abs(path) + if err != nil { + return fmt.Errorf("resolving path: %w", err) + } + + if !analysis.RepositoryExists(absPath) { + return fmt.Errorf("not a git repository: %s", absPath) + } + + gen := releasenotes.NewGenerator(absPath) + + opts := releasenotes.Options{ + Tag: tag, + PrevTag: prevTag, + IncludeCommitHashes: !noHashes, + IncludeAuthors: authors, + GroupByCategory: !flat, + } + + rn, err := gen.Generate(opts) + if err != nil { + return fmt.Errorf("generating release notes: %w", err) + } + + if jsonOutput { + return outputReleaseNotesJSON(rn) + } + + fmt.Print(rn.Render(opts)) + return nil +} + +type releaseNotesJSON struct { + Version string `json:"version"` + PrevTag string `json:"prev_tag,omitempty"` + Date string `json:"date"` + Commits int `json:"total_commits"` + Categories map[string][]releaseNoteCommitJSON `json:"categories"` +} + +type releaseNoteCommitJSON struct { + Hash string `json:"hash"` + Subject string `json:"subject"` + Author string `json:"author"` + Date string `json:"date"` + Scope string `json:"scope,omitempty"` + Breaking bool `json:"breaking,omitempty"` +} + +func outputReleaseNotesJSON(rn *releasenotes.ReleaseNotes) error { + cats := make(map[string][]releaseNoteCommitJSON) + for cat, commits := range rn.Categories { + var entries []releaseNoteCommitJSON + for _, c := range commits { + entries = append(entries, releaseNoteCommitJSON{ + Hash: c.ShortHash, + Subject: c.Subject, + Author: c.Author, + Date: c.Date.Format("2006-01-02"), + Scope: c.Scope, + Breaking: c.Breaking, + }) + } + cats[string(cat)] = entries + } + + out := releaseNotesJSON{ + Version: rn.Version, + PrevTag: rn.PrevTag, + Date: rn.Date.Format("2006-01-02"), + Commits: len(rn.AllCommits), + Categories: cats, + } + + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(out) +} diff --git a/cmd/nightshift/commands/releasenotes_test.go b/cmd/nightshift/commands/releasenotes_test.go new file mode 100644 index 0000000..554dbb5 --- /dev/null +++ b/cmd/nightshift/commands/releasenotes_test.go @@ -0,0 +1,102 @@ +package commands + +import ( + "os" + "os/exec" + "path/filepath" + "testing" +) + +func TestRunReleaseNotesInvalidPath(t *testing.T) { + err := runReleaseNotes("/nonexistent/path", "", "", false, false, false, false) + if err == nil { + t.Fatal("expected error for nonexistent path") + } +} + +func TestRunReleaseNotesNotGitRepo(t *testing.T) { + dir := t.TempDir() + err := runReleaseNotes(dir, "", "", false, false, false, false) + if err == nil { + t.Fatal("expected error for non-git directory") + } +} + +func TestRunReleaseNotesWithRepo(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } + + dir := t.TempDir() + gitRun(t, dir, "init") + gitRun(t, dir, "config", "user.email", "test@test.com") + gitRun(t, dir, "config", "user.name", "Test") + + writeTestFile(t, dir, "README.md", "# Test\n") + gitRun(t, dir, "add", ".") + gitRun(t, dir, "commit", "-m", "feat: initial commit") + gitRun(t, dir, "tag", "v0.1.0") + + writeTestFile(t, dir, "main.go", "package main\n") + gitRun(t, dir, "add", ".") + gitRun(t, dir, "commit", "-m", "feat: add main") + gitRun(t, dir, "tag", "v0.2.0") + + t.Run("markdown output", func(t *testing.T) { + err := runReleaseNotes(dir, "v0.2.0", "", false, false, false, false) + if err != nil { + t.Fatalf("runReleaseNotes: %v", err) + } + }) + + t.Run("json output", func(t *testing.T) { + err := runReleaseNotes(dir, "v0.2.0", "", false, true, false, false) + if err != nil { + t.Fatalf("runReleaseNotes JSON: %v", err) + } + }) + + t.Run("flat output", func(t *testing.T) { + err := runReleaseNotes(dir, "v0.2.0", "", true, false, false, false) + if err != nil { + t.Fatalf("runReleaseNotes flat: %v", err) + } + }) + + t.Run("with authors", func(t *testing.T) { + err := runReleaseNotes(dir, "v0.2.0", "", false, false, false, true) + if err != nil { + t.Fatalf("runReleaseNotes authors: %v", err) + } + }) + + t.Run("no hashes", func(t *testing.T) { + err := runReleaseNotes(dir, "v0.2.0", "", false, false, true, false) + if err != nil { + t.Fatalf("runReleaseNotes no-hashes: %v", err) + } + }) +} + +func gitRun(t *testing.T, dir string, args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_DATE=2026-02-16T00:00:00Z", + "GIT_COMMITTER_DATE=2026-02-16T00:00:00Z", + ) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } +} + +func writeTestFile(t *testing.T, dir, name, content string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0644); err != nil { + t.Fatalf("writing %s: %v", name, err) + } +} diff --git a/internal/releasenotes/releasenotes.go b/internal/releasenotes/releasenotes.go new file mode 100644 index 0000000..cf0e19c --- /dev/null +++ b/internal/releasenotes/releasenotes.go @@ -0,0 +1,457 @@ +// Package releasenotes generates release notes from git history between tags. +package releasenotes + +import ( + "fmt" + "os/exec" + "regexp" + "sort" + "strings" + "time" +) + +// CommitCategory classifies a commit by its type. +type CommitCategory string + +const ( + CategoryFeatures CommitCategory = "Features" + CategoryFixes CommitCategory = "Bug Fixes" + CategorySecurity CommitCategory = "Security" + CategoryPerformance CommitCategory = "Performance" + CategoryDocs CommitCategory = "Documentation" + CategoryRefactor CommitCategory = "Refactoring" + CategoryTests CommitCategory = "Tests" + CategoryBuild CommitCategory = "Build & CI" + CategoryBreaking CommitCategory = "Breaking Changes" + CategoryOther CommitCategory = "Other Changes" +) + +// categoryOrder defines the display order of categories. +var categoryOrder = []CommitCategory{ + CategoryBreaking, + CategoryFeatures, + CategorySecurity, + CategoryFixes, + CategoryPerformance, + CategoryRefactor, + CategoryDocs, + CategoryTests, + CategoryBuild, + CategoryOther, +} + +// Commit represents a parsed git commit. +type Commit struct { + Hash string + ShortHash string + Subject string + Body string + Author string + Date time.Time + Category CommitCategory + Scope string // Optional scope from conventional commits, e.g. "config" in "feat(config): ..." + Breaking bool +} + +// TagInfo represents a git tag with its associated metadata. +type TagInfo struct { + Name string + Hash string + Date time.Time +} + +// ReleaseNotes holds the generated release notes content. +type ReleaseNotes struct { + Version string + PrevTag string + Date time.Time + Categories map[CommitCategory][]Commit + AllCommits []Commit + RepoPath string +} + +// Generator creates release notes from git history. +type Generator struct { + repoPath string +} + +// NewGenerator creates a release notes generator for the given repository. +func NewGenerator(repoPath string) *Generator { + return &Generator{repoPath: repoPath} +} + +// Options controls release note generation behavior. +type Options struct { + // Tag to generate notes for. If empty, uses HEAD. + Tag string + // PrevTag to compare against. If empty, auto-detects previous tag. + PrevTag string + // IncludeCommitHashes includes short commit hashes in output. + IncludeCommitHashes bool + // IncludeAuthors includes author names in output. + IncludeAuthors bool + // GroupByCategory groups commits by conventional commit type. + GroupByCategory bool +} + +// DefaultOptions returns sensible defaults for release note generation. +func DefaultOptions() Options { + return Options{ + IncludeCommitHashes: true, + IncludeAuthors: false, + GroupByCategory: true, + } +} + +// Generate creates release notes from git history. +func (g *Generator) Generate(opts Options) (*ReleaseNotes, error) { + // Determine the tag range + tag := opts.Tag + if tag == "" { + latest, err := g.latestTag() + if err != nil || latest == "" { + tag = "HEAD" + } else { + tag = latest + } + } + + prevTag := opts.PrevTag + if prevTag == "" { + var err error + prevTag, err = g.previousTag(tag) + if err != nil { + prevTag = "" // Will use full history + } + } + + // Get commits in range + commits, err := g.commitsInRange(prevTag, tag) + if err != nil { + return nil, fmt.Errorf("getting commits: %w", err) + } + + if len(commits) == 0 { + return nil, fmt.Errorf("no commits found between %q and %q", prevTag, tag) + } + + // Categorize commits + categories := make(map[CommitCategory][]Commit) + for i := range commits { + commits[i].Category, commits[i].Scope, commits[i].Breaking = classifyCommit(commits[i].Subject) + cat := commits[i].Category + if commits[i].Breaking { + categories[CategoryBreaking] = append(categories[CategoryBreaking], commits[i]) + } + categories[cat] = append(categories[cat], commits[i]) + } + + version := tag + if version == "HEAD" { + version = "Unreleased" + } + + return &ReleaseNotes{ + Version: version, + PrevTag: prevTag, + Date: time.Now(), + Categories: categories, + AllCommits: commits, + RepoPath: g.repoPath, + }, nil +} + +// latestTag returns the most recent semver tag reachable from HEAD. +func (g *Generator) latestTag() (string, error) { + cmd := exec.Command("git", "describe", "--tags", "--abbrev=0", "HEAD") + cmd.Dir = g.repoPath + out, err := cmd.Output() + if err != nil { + return "", err + } + return strings.TrimSpace(string(out)), nil +} + +// previousTag returns the tag before the given tag. +func (g *Generator) previousTag(tag string) (string, error) { + ref := tag + if ref == "HEAD" { + // For HEAD, find the latest tag first, then the one before it + latest, err := g.latestTag() + if err != nil || latest == "" { + return "", fmt.Errorf("no tags found") + } + return latest, nil + } + + // Find the tag before this one + cmd := exec.Command("git", "describe", "--tags", "--abbrev=0", ref+"^") + cmd.Dir = g.repoPath + out, err := cmd.Output() + if err != nil { + return "", err + } + return strings.TrimSpace(string(out)), nil +} + +// commitsInRange returns commits between two refs. +func (g *Generator) commitsInRange(from, to string) ([]Commit, error) { + rangeSpec := to + if from != "" { + rangeSpec = from + ".." + to + } + + // Use a delimiter that won't appear in commit messages + const delim = "---NIGHTSHIFT-COMMIT-DELIM---" + format := strings.Join([]string{"%H", "%h", "%s", "%b", "%an", "%aI"}, "%x00") + delim + + cmd := exec.Command("git", "log", "--format="+format, rangeSpec) + cmd.Dir = g.repoPath + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("git log: %w", err) + } + + raw := strings.TrimSpace(string(out)) + if raw == "" { + return nil, nil + } + + entries := strings.Split(raw, delim) + var commits []Commit + for _, entry := range entries { + entry = strings.TrimSpace(entry) + if entry == "" { + continue + } + + parts := strings.Split(entry, "\x00") + if len(parts) < 6 { + continue + } + + date, _ := time.Parse(time.RFC3339, strings.TrimSpace(parts[5])) + + commits = append(commits, Commit{ + Hash: strings.TrimSpace(parts[0]), + ShortHash: strings.TrimSpace(parts[1]), + Subject: strings.TrimSpace(parts[2]), + Body: strings.TrimSpace(parts[3]), + Author: strings.TrimSpace(parts[4]), + Date: date, + }) + } + + return commits, nil +} + +// Tags returns all semver-like tags in chronological order. +func (g *Generator) Tags() ([]TagInfo, error) { + cmd := exec.Command("git", "tag", "--sort=-creatordate", "--format=%(refname:short)%00%(objectname:short)%00%(creatordate:iso-strict)") + cmd.Dir = g.repoPath + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("git tag: %w", err) + } + + raw := strings.TrimSpace(string(out)) + if raw == "" { + return nil, nil + } + + var tags []TagInfo + for _, line := range strings.Split(raw, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + parts := strings.Split(line, "\x00") + if len(parts) < 3 { + continue + } + date, _ := time.Parse(time.RFC3339, strings.TrimSpace(parts[2])) + tags = append(tags, TagInfo{ + Name: parts[0], + Hash: parts[1], + Date: date, + }) + } + + return tags, nil +} + +// conventionalCommitRe matches conventional commit subjects like: +// feat(scope): description +// fix: description +// feat!: breaking change +var conventionalCommitRe = regexp.MustCompile(`^(\w+)(?:\(([^)]+)\))?(!)?\s*:\s*(.+)$`) + +// classifyCommit categorizes a commit based on its subject line. +func classifyCommit(subject string) (CommitCategory, string, bool) { + m := conventionalCommitRe.FindStringSubmatch(subject) + if m == nil { + return inferCategory(subject), "", false + } + + prefix := strings.ToLower(m[1]) + scope := m[2] + breaking := m[3] == "!" + + cat := prefixToCategory(prefix) + return cat, scope, breaking +} + +// prefixToCategory maps conventional commit prefixes to categories. +func prefixToCategory(prefix string) CommitCategory { + switch prefix { + case "feat", "feature": + return CategoryFeatures + case "fix", "bugfix": + return CategoryFixes + case "security", "sec": + return CategorySecurity + case "perf", "performance": + return CategoryPerformance + case "docs", "doc": + return CategoryDocs + case "refactor": + return CategoryRefactor + case "test", "tests": + return CategoryTests + case "build", "ci", "chore": + return CategoryBuild + default: + return CategoryOther + } +} + +// inferCategory attempts to classify non-conventional commit messages by keywords. +func inferCategory(subject string) CommitCategory { + lower := strings.ToLower(subject) + + switch { + case strings.HasPrefix(lower, "add ") || strings.HasPrefix(lower, "implement ") || + strings.HasPrefix(lower, "introduce "): + return CategoryFeatures + case strings.HasPrefix(lower, "fix ") || strings.Contains(lower, "bugfix"): + return CategoryFixes + case strings.Contains(lower, "security") || strings.Contains(lower, "vulnerability") || + strings.Contains(lower, "cve"): + return CategorySecurity + case strings.Contains(lower, "perf") || strings.Contains(lower, "optimize") || + strings.Contains(lower, "speed"): + return CategoryPerformance + case strings.Contains(lower, "refactor") || strings.Contains(lower, "restructur") || + strings.Contains(lower, "clean up"): + return CategoryRefactor + case strings.HasPrefix(lower, "doc") || strings.Contains(lower, "documentation") || + strings.Contains(lower, "readme") || strings.Contains(lower, "changelog"): + return CategoryDocs + case strings.Contains(lower, "test"): + return CategoryTests + case strings.HasPrefix(lower, "build") || strings.HasPrefix(lower, "ci") || + strings.Contains(lower, "ci/cd") || strings.Contains(lower, "makefile") || + strings.Contains(lower, "goreleaser"): + return CategoryBuild + default: + return CategoryOther + } +} + +// Render generates the release notes as a markdown string. +func (rn *ReleaseNotes) Render(opts Options) string { + var buf strings.Builder + + // Header + dateStr := rn.Date.Format("2006-01-02") + buf.WriteString(fmt.Sprintf("# Release Notes: %s\n\n", rn.Version)) + buf.WriteString(fmt.Sprintf("**Date:** %s\n", dateStr)) + if rn.PrevTag != "" { + buf.WriteString(fmt.Sprintf("**Compared to:** %s\n", rn.PrevTag)) + } + buf.WriteString(fmt.Sprintf("**Commits:** %d\n\n", len(rn.AllCommits))) + + if !opts.GroupByCategory { + // Flat list + for _, c := range rn.AllCommits { + buf.WriteString(formatCommitLine(c, opts)) + } + return buf.String() + } + + // Grouped by category + for _, cat := range categoryOrder { + commits, ok := rn.Categories[cat] + if !ok || len(commits) == 0 { + continue + } + + // Deduplicate: breaking commits appear under both Breaking and their own category + if cat == CategoryBreaking { + buf.WriteString(fmt.Sprintf("## %s\n\n", cat)) + } else { + // Filter out commits already listed under Breaking + filtered := filterNonBreaking(commits) + if len(filtered) == 0 { + continue + } + commits = filtered + buf.WriteString(fmt.Sprintf("## %s\n\n", cat)) + } + + // Sort commits by date descending + sort.Slice(commits, func(i, j int) bool { + return commits[i].Date.After(commits[j].Date) + }) + + for _, c := range commits { + buf.WriteString(formatCommitLine(c, opts)) + } + buf.WriteString("\n") + } + + return buf.String() +} + +// filterNonBreaking removes commits that are marked as breaking changes. +func filterNonBreaking(commits []Commit) []Commit { + var out []Commit + for _, c := range commits { + if !c.Breaking { + out = append(out, c) + } + } + return out +} + +// formatCommitLine formats a single commit as a markdown list item. +func formatCommitLine(c Commit, opts Options) string { + subject := c.Subject + + // Strip conventional commit prefix for cleaner output + if m := conventionalCommitRe.FindStringSubmatch(subject); m != nil { + subject = m[4] // The description part after "type(scope): " + } + + // Capitalize first letter + if len(subject) > 0 { + subject = strings.ToUpper(subject[:1]) + subject[1:] + } + + var parts []string + parts = append(parts, fmt.Sprintf("- %s", subject)) + + if c.Scope != "" { + parts = append(parts, fmt.Sprintf("(%s)", c.Scope)) + } + + if opts.IncludeCommitHashes { + parts = append(parts, fmt.Sprintf("[`%s`]", c.ShortHash)) + } + + if opts.IncludeAuthors { + parts = append(parts, fmt.Sprintf("— %s", c.Author)) + } + + return strings.Join(parts, " ") + "\n" +} diff --git a/internal/releasenotes/releasenotes_test.go b/internal/releasenotes/releasenotes_test.go new file mode 100644 index 0000000..2c24690 --- /dev/null +++ b/internal/releasenotes/releasenotes_test.go @@ -0,0 +1,364 @@ +package releasenotes + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestClassifyCommit(t *testing.T) { + tests := []struct { + subject string + wantCat CommitCategory + wantScope string + wantBreak bool + }{ + // Conventional commits + {"feat: add new feature", CategoryFeatures, "", false}, + {"feat(auth): add login endpoint", CategoryFeatures, "auth", false}, + {"fix: resolve crash on startup", CategoryFixes, "", false}, + {"fix(db): handle nil pointer", CategoryFixes, "db", false}, + {"docs: update README", CategoryDocs, "", false}, + {"test: add unit tests", CategoryTests, "", false}, + {"refactor: simplify logic", CategoryRefactor, "", false}, + {"perf: optimize query", CategoryPerformance, "", false}, + {"ci: update workflow", CategoryBuild, "", false}, + {"build: update Makefile", CategoryBuild, "", false}, + {"chore: bump dependencies", CategoryBuild, "", false}, + {"security: patch vulnerability", CategorySecurity, "", false}, + + // Breaking changes + {"feat!: remove deprecated API", CategoryFeatures, "", true}, + {"fix(api)!: change response format", CategoryFixes, "api", true}, + + // Non-conventional commits (keyword inference) + {"Add user authentication", CategoryFeatures, "", false}, + {"Fix memory leak in parser", CategoryFixes, "", false}, + {"Implement caching layer", CategoryFeatures, "", false}, + {"Optimize database queries", CategoryPerformance, "", false}, + {"Refactor middleware stack", CategoryRefactor, "", false}, + {"Update documentation for v2", CategoryDocs, "", false}, + {"random commit message", CategoryOther, "", false}, + } + + for _, tt := range tests { + t.Run(tt.subject, func(t *testing.T) { + cat, scope, breaking := classifyCommit(tt.subject) + if cat != tt.wantCat { + t.Errorf("classifyCommit(%q) category = %q, want %q", tt.subject, cat, tt.wantCat) + } + if scope != tt.wantScope { + t.Errorf("classifyCommit(%q) scope = %q, want %q", tt.subject, scope, tt.wantScope) + } + if breaking != tt.wantBreak { + t.Errorf("classifyCommit(%q) breaking = %v, want %v", tt.subject, breaking, tt.wantBreak) + } + }) + } +} + +func TestRender(t *testing.T) { + now := time.Date(2026, 2, 16, 0, 0, 0, 0, time.UTC) + rn := &ReleaseNotes{ + Version: "v1.0.0", + PrevTag: "v0.9.0", + Date: now, + AllCommits: []Commit{ + {ShortHash: "abc1234", Subject: "feat: add login", Author: "Alice", Date: now, Category: CategoryFeatures}, + {ShortHash: "def5678", Subject: "fix: crash on nil", Author: "Bob", Date: now, Category: CategoryFixes}, + {ShortHash: "ghi9012", Subject: "feat!: remove old API", Author: "Alice", Date: now, Category: CategoryFeatures, Breaking: true}, + }, + Categories: map[CommitCategory][]Commit{ + CategoryFeatures: { + {ShortHash: "abc1234", Subject: "feat: add login", Author: "Alice", Date: now, Category: CategoryFeatures}, + {ShortHash: "ghi9012", Subject: "feat!: remove old API", Author: "Alice", Date: now, Category: CategoryFeatures, Breaking: true}, + }, + CategoryFixes: { + {ShortHash: "def5678", Subject: "fix: crash on nil", Author: "Bob", Date: now, Category: CategoryFixes}, + }, + CategoryBreaking: { + {ShortHash: "ghi9012", Subject: "feat!: remove old API", Author: "Alice", Date: now, Category: CategoryFeatures, Breaking: true}, + }, + }, + } + + t.Run("grouped with hashes", func(t *testing.T) { + opts := Options{ + GroupByCategory: true, + IncludeCommitHashes: true, + } + result := rn.Render(opts) + + if !strings.Contains(result, "# Release Notes: v1.0.0") { + t.Error("missing header") + } + if !strings.Contains(result, "## Breaking Changes") { + t.Error("missing breaking changes section") + } + if !strings.Contains(result, "## Features") { + t.Error("missing features section") + } + if !strings.Contains(result, "## Bug Fixes") { + t.Error("missing fixes section") + } + if !strings.Contains(result, "[`abc1234`]") { + t.Error("missing commit hash") + } + if !strings.Contains(result, "**Compared to:** v0.9.0") { + t.Error("missing previous tag comparison") + } + if !strings.Contains(result, "**Commits:** 3") { + t.Error("missing commit count") + } + }) + + t.Run("flat list", func(t *testing.T) { + opts := Options{ + GroupByCategory: false, + IncludeCommitHashes: true, + } + result := rn.Render(opts) + + if strings.Contains(result, "## Features") { + t.Error("should not have category headers in flat mode") + } + // Should still have commits + if !strings.Contains(result, "abc1234") { + t.Error("missing commit in flat mode") + } + }) + + t.Run("with authors", func(t *testing.T) { + opts := Options{ + GroupByCategory: true, + IncludeCommitHashes: false, + IncludeAuthors: true, + } + result := rn.Render(opts) + + if !strings.Contains(result, "— Alice") { + t.Error("missing author") + } + if strings.Contains(result, "[`abc1234`]") { + t.Error("should not have commit hashes when disabled") + } + }) +} + +func TestFormatCommitLine(t *testing.T) { + c := Commit{ + ShortHash: "abc1234", + Subject: "feat(auth): add login endpoint", + Author: "Alice", + Scope: "auth", + } + + t.Run("with hash", func(t *testing.T) { + line := formatCommitLine(c, Options{IncludeCommitHashes: true}) + if !strings.Contains(line, "- Add login endpoint") { + t.Errorf("expected cleaned subject, got: %s", line) + } + if !strings.Contains(line, "(auth)") { + t.Errorf("expected scope, got: %s", line) + } + if !strings.Contains(line, "[`abc1234`]") { + t.Errorf("expected hash, got: %s", line) + } + }) + + t.Run("without hash", func(t *testing.T) { + line := formatCommitLine(c, Options{}) + if strings.Contains(line, "abc1234") { + t.Error("should not include hash when disabled") + } + }) +} + +func TestFilterNonBreaking(t *testing.T) { + commits := []Commit{ + {Subject: "breaking", Breaking: true}, + {Subject: "normal", Breaking: false}, + {Subject: "also breaking", Breaking: true}, + } + + filtered := filterNonBreaking(commits) + if len(filtered) != 1 { + t.Fatalf("expected 1 non-breaking commit, got %d", len(filtered)) + } + if filtered[0].Subject != "normal" { + t.Errorf("expected 'normal', got %q", filtered[0].Subject) + } +} + +func TestPrefixToCategory(t *testing.T) { + tests := []struct { + prefix string + want CommitCategory + }{ + {"feat", CategoryFeatures}, + {"feature", CategoryFeatures}, + {"fix", CategoryFixes}, + {"bugfix", CategoryFixes}, + {"security", CategorySecurity}, + {"sec", CategorySecurity}, + {"perf", CategoryPerformance}, + {"performance", CategoryPerformance}, + {"docs", CategoryDocs}, + {"doc", CategoryDocs}, + {"refactor", CategoryRefactor}, + {"test", CategoryTests}, + {"tests", CategoryTests}, + {"build", CategoryBuild}, + {"ci", CategoryBuild}, + {"chore", CategoryBuild}, + {"unknown", CategoryOther}, + } + + for _, tt := range tests { + t.Run(tt.prefix, func(t *testing.T) { + got := prefixToCategory(tt.prefix) + if got != tt.want { + t.Errorf("prefixToCategory(%q) = %q, want %q", tt.prefix, got, tt.want) + } + }) + } +} + +// TestGenerateWithRealRepo tests the full generation flow using a temporary git repo. +func TestGenerateWithRealRepo(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + // Check git is available + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } + + // Create temp repo + dir := t.TempDir() + mustRun(t, dir, "git", "init") + mustRun(t, dir, "git", "config", "user.email", "test@test.com") + mustRun(t, dir, "git", "config", "user.name", "Test User") + + // Create initial commit and tag + writeFile(t, dir, "README.md", "# Test\n") + mustRun(t, dir, "git", "add", ".") + mustRun(t, dir, "git", "commit", "-m", "feat: initial commit") + mustRun(t, dir, "git", "tag", "v0.1.0") + + // Add more commits + writeFile(t, dir, "main.go", "package main\n") + mustRun(t, dir, "git", "add", ".") + mustRun(t, dir, "git", "commit", "-m", "feat(core): add main package") + + writeFile(t, dir, "bug.go", "package main\n// fixed\n") + mustRun(t, dir, "git", "add", ".") + mustRun(t, dir, "git", "commit", "-m", "fix: resolve startup crash") + + writeFile(t, dir, "api.go", "package main\n// new api\n") + mustRun(t, dir, "git", "add", ".") + mustRun(t, dir, "git", "commit", "-m", "feat!: redesign API surface") + + // Tag new version + mustRun(t, dir, "git", "tag", "v0.2.0") + + gen := NewGenerator(dir) + + t.Run("generate for latest tag", func(t *testing.T) { + rn, err := gen.Generate(Options{ + Tag: "v0.2.0", + IncludeCommitHashes: true, + GroupByCategory: true, + }) + if err != nil { + t.Fatalf("Generate: %v", err) + } + + if rn.Version != "v0.2.0" { + t.Errorf("version = %q, want v0.2.0", rn.Version) + } + if rn.PrevTag != "v0.1.0" { + t.Errorf("prevTag = %q, want v0.1.0", rn.PrevTag) + } + if len(rn.AllCommits) != 3 { + t.Errorf("expected 3 commits, got %d", len(rn.AllCommits)) + } + + // Check categories + if len(rn.Categories[CategoryFeatures]) != 2 { + t.Errorf("expected 2 feature commits, got %d", len(rn.Categories[CategoryFeatures])) + } + if len(rn.Categories[CategoryFixes]) != 1 { + t.Errorf("expected 1 fix commit, got %d", len(rn.Categories[CategoryFixes])) + } + if len(rn.Categories[CategoryBreaking]) != 1 { + t.Errorf("expected 1 breaking commit, got %d", len(rn.Categories[CategoryBreaking])) + } + + // Render and check output + output := rn.Render(DefaultOptions()) + if !strings.Contains(output, "## Features") { + t.Error("rendered output missing Features section") + } + if !strings.Contains(output, "## Breaking Changes") { + t.Error("rendered output missing Breaking Changes section") + } + }) + + t.Run("generate auto-detect HEAD", func(t *testing.T) { + // Add a commit after the tag + writeFile(t, dir, "new.go", "package main\n// new\n") + mustRun(t, dir, "git", "add", ".") + mustRun(t, dir, "git", "commit", "-m", "feat: post-release feature") + + rn, err := gen.Generate(Options{ + GroupByCategory: true, + }) + if err != nil { + t.Fatalf("Generate HEAD: %v", err) + } + + // HEAD should compare against latest tag + if rn.Version != "HEAD" || rn.Version == "" { + // HEAD with commits after the latest tag + } + if len(rn.AllCommits) == 0 { + t.Error("expected commits for HEAD") + } + }) + + t.Run("tags listing", func(t *testing.T) { + tags, err := gen.Tags() + if err != nil { + t.Fatalf("Tags: %v", err) + } + if len(tags) < 2 { + t.Errorf("expected at least 2 tags, got %d", len(tags)) + } + }) +} + +func mustRun(t *testing.T, dir string, name string, args ...string) { + t.Helper() + cmd := exec.Command(name, args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_DATE=2026-02-16T00:00:00Z", + "GIT_COMMITTER_DATE=2026-02-16T00:00:00Z", + ) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("%s %v failed: %v\n%s", name, args, err, out) + } +} + +func writeFile(t *testing.T, dir, name, content string) { + t.Helper() + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatalf("writing %s: %v", path, err) + } +} diff --git a/internal/tasks/tasks.go b/internal/tasks/tasks.go index 9ab3fc7..16cac4e 100644 --- a/internal/tasks/tasks.go +++ b/internal/tasks/tasks.go @@ -347,10 +347,15 @@ Apply safe updates directly, and leave concise follow-ups for anything uncertain DefaultInterval: 168 * time.Hour, }, TaskReleaseNotes: { - Type: TaskReleaseNotes, - Category: CategoryPR, - Name: "Release Note Drafter", - Description: "Draft release notes from changes", + Type: TaskReleaseNotes, + Category: CategoryPR, + Name: "Release Note Drafter", + Description: `Draft release notes by analyzing git history between tags. ` + + `Run 'nightshift release-notes' to inspect the latest tag range, then review ` + + `the generated markdown for accuracy. Group commits by category (features, fixes, ` + + `security, performance, etc.) using conventional-commit prefixes. Highlight breaking ` + + `changes prominently. Update or create a RELEASE_NOTES.md (or the project's preferred ` + + `release notes file) and open a PR with the draft.`, CostTier: CostLow, RiskLevel: RiskLow, DefaultInterval: 168 * time.Hour,