Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 146 additions & 0 deletions cmd/nightshift/commands/releasenotes.go
Original file line number Diff line number Diff line change
@@ -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)
}
102 changes: 102 additions & 0 deletions cmd/nightshift/commands/releasenotes_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading