From ce5367c3a75590be5ad52b8a69a8d7b8e3de53d3 Mon Sep 17 00:00:00 2001 From: Greg Gauthier Date: Tue, 3 Mar 2026 21:59:09 +0000 Subject: [PATCH] feat(cmd): add changelog command for AI-generated release notes - Implement `grokkit changelog` command with flags for version, since, stdout, and commit reminder - Add git helpers for latest/previous tags and formatted log since ref - Include tests for message building and full changelog construction --- cmd/changelog.go | 143 ++++++++++++++++++++++++++++++++++++++++++ cmd/changelog_test.go | 106 +++++++++++++++++++++++++++++++ internal/git/git.go | 23 +++++++ 3 files changed, 272 insertions(+) create mode 100644 cmd/changelog.go create mode 100644 cmd/changelog_test.go diff --git a/cmd/changelog.go b/cmd/changelog.go new file mode 100644 index 0000000..c992c7e --- /dev/null +++ b/cmd/changelog.go @@ -0,0 +1,143 @@ +package cmd + +import ( + "fmt" + "os" + "strings" + "time" + + "github.com/fatih/color" + "github.com/spf13/cobra" + "gmgauthier.com/grokkit/config" + "gmgauthier.com/grokkit/internal/git" +) + +var changelogCmd = &cobra.Command{ + Use: "changelog", + Short: "Generate CHANGELOG.md section from git history for Gitea releases", + Long: `AI-generated changelog using only Added/Changed/Fixed. Designed so the output can be pasted directly into Gitea release notes.`, + Run: runChangelog, +} + +func init() { + changelogCmd.Flags().String("since", "", "Start from this tag/ref (default: previous tag)") + changelogCmd.Flags().StringP("version", "v", "", "Override version for header (default: latest git tag)") + changelogCmd.Flags().Bool("stdout", false, "Print ONLY the new section (ideal for Gitea release notes)") + changelogCmd.Flags().Bool("commit", false, "After writing, remind to run grokkit commit") + rootCmd.AddCommand(changelogCmd) +} + +func runChangelog(cmd *cobra.Command, args []string) { + stdout, _ := cmd.Flags().GetBool("stdout") + doCommit, _ := cmd.Flags().GetBool("commit") + version, _ := cmd.Flags().GetString("version") + since, _ := cmd.Flags().GetString("since") + + if !git.IsRepo() { + color.Red("Not inside a git repository") + return + } + + // Version from tag you set (or override) + if version == "" { + var err error + version, err = git.LatestTag() + if err != nil { + color.Red("No git tags found. Create one first: git tag vX.Y.Z") + return + } + } + + // Since ref (defaults to previous tag) + if since == "" { + if prev, err := git.PreviousTag(version); err == nil { + since = prev + } else { + since = "HEAD~100" // safe first-release fallback + } + } + + logOutput, err := git.LogSince(since) + if err != nil || strings.TrimSpace(logOutput) == "" { + color.Yellow("No new commits since last tag.") + return + } + + // Grok generation (strong prompt for your exact format) + client := newGrokClient() + messages := buildChangelogMessages(logOutput, version) + model := config.GetModel("changelog", "") + + color.Yellow("Asking Grok to categorize changes...") + section := client.Stream(messages, model) + + date := time.Now().Format("2006-01-02") + newSection := fmt.Sprintf("## [%s] - %s\n\n%s\n", version, date, section) + + if stdout { + fmt.Print(newSection) + return + } + + // Build full file (prepend or create) + content := buildFullChangelog(newSection) + + // Preview + safety confirm (exactly like commit/review) + color.Cyan("\n--- Proposed CHANGELOG.md update ---\n%s\n--------------------------------", content) + var confirm string + color.Yellow("Write this to CHANGELOG.md? (y/n): ") + _, _ = fmt.Scanln(&confirm) // we don't care about scan errors here + + if confirm != "y" && confirm != "Y" { + color.Yellow("Aborted.") + return + } + + if err := os.WriteFile("CHANGELOG.md", []byte(content), 0644); err != nil { + color.Red("Failed to write CHANGELOG.md") + return + } + + color.Green("✅ CHANGELOG.md updated with version %s", version) + + if doCommit { + color.Yellow("Run `grokkit commit` (or `git add CHANGELOG.md && git commit`) to stage it.") + } +} + +func buildChangelogMessages(log, version string) []map[string]string { + return []map[string]string{ + { + "role": "system", + "content": `You are an expert technical writer. +Generate a changelog section using **only** these headings (include only if content exists): +### Added +### Changed +### Fixed + +Rules: +- Start directly with the section headings (no extra header — we add it). +- One optional short summary sentence at the very top (light humour OK here only). +- Every bullet must be factual, imperative, one clear action per line. +- Be concise. No marketing language or explanations. +Output ONLY clean markdown.`, + }, + { + "role": "user", + "content": fmt.Sprintf("Version: %s\n\nCommit history:\n%s", version, log), + }, + } +} + +func buildFullChangelog(newSection string) string { + existing, err := os.ReadFile("CHANGELOG.md") + if err != nil { + // File doesn't exist (or unreadable) → create a new changelog with a header + return `# Changelog + +All notable changes to this project will be documented in this file. + +` + newSection + } + return newSection + string(existing) +} diff --git a/cmd/changelog_test.go b/cmd/changelog_test.go new file mode 100644 index 0000000..a4f71b6 --- /dev/null +++ b/cmd/changelog_test.go @@ -0,0 +1,106 @@ +package cmd + +import ( + "os" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBuildChangelogMessages(t *testing.T) { + t.Parallel() + + log := `feat: add changelog command + +Implements #1 +--- +fix: typo in docs +---` + version := "v0.2.0" + + messages := buildChangelogMessages(log, version) + + require.Len(t, messages, 2) + assert.Equal(t, "system", messages[0]["role"]) + assert.Contains(t, messages[0]["content"], "Generate a changelog section") + assert.Contains(t, messages[0]["content"], "### Added") + assert.Contains(t, messages[0]["content"], "### Changed") + assert.Contains(t, messages[0]["content"], "### Fixed") + assert.Equal(t, "user", messages[1]["role"]) + assert.Contains(t, messages[1]["content"], "Version: v0.2.0") + assert.Contains(t, messages[1]["content"], log) +} + +func TestBuildFullChangelog(t *testing.T) { + t.Parallel() + + newSection := "## [v0.2.0] - 2026-03-03\n\n### Added\n- changelog command\n" + + t.Run("creates new file with header", func(t *testing.T) { + tmpDir := t.TempDir() + + originalWd, err := os.Getwd() + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, os.Chdir(originalWd)) + }) + require.NoError(t, os.Chdir(tmpDir)) + + result := buildFullChangelog(newSection) + + assert.Contains(t, result, "# Changelog") + assert.Contains(t, result, "All notable changes to this project will be documented in this file.") + assert.Contains(t, result, newSection) + }) + + t.Run("prepends to existing file", func(t *testing.T) { + tmpDir := t.TempDir() + + originalWd, err := os.Getwd() + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, os.Chdir(originalWd)) + }) + require.NoError(t, os.Chdir(tmpDir)) + + // Simulate existing changelog + existing := `# Changelog + +All notable changes to this project will be documented in this file. + +## [v0.1.0] - 2025-01-01 + +### Fixed +- old bug +` + require.NoError(t, os.WriteFile("CHANGELOG.md", []byte(existing), 0644)) + + result := buildFullChangelog(newSection) + + assert.True(t, strings.HasPrefix(result, newSection), "new version section must be prepended at the top") + assert.Contains(t, result, "old bug") + }) +} + +func TestChangelogCmd_Flags(t *testing.T) { + t.Parallel() + + require.NotNil(t, changelogCmd) + + stdoutFlag := changelogCmd.Flags().Lookup("stdout") + require.NotNil(t, stdoutFlag) + assert.Equal(t, "Print ONLY the new section (ideal for Gitea release notes)", stdoutFlag.Usage) + + versionFlag := changelogCmd.Flags().Lookup("version") + require.NotNil(t, versionFlag) + assert.Equal(t, "v", versionFlag.Shorthand) + + commitFlag := changelogCmd.Flags().Lookup("commit") + require.NotNil(t, commitFlag) + assert.Equal(t, "After writing, remind to run grokkit commit", commitFlag.Usage) + + sinceFlag := changelogCmd.Flags().Lookup("since") + require.NotNil(t, sinceFlag) +} diff --git a/internal/git/git.go b/internal/git/git.go index b154cf3..b0ef0b0 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -35,3 +35,26 @@ func IsRepo() bool { logger.Debug("git repository check completed", "is_repo", isRepo) return isRepo } + +func LatestTag() (string, error) { + out, err := Run([]string{"describe", "--tags", "--abbrev=0"}) + if err != nil { + return "", err + } + return strings.TrimSpace(out), nil +} + +// PreviousTag returns the tag immediately before the given one. +func PreviousTag(current string) (string, error) { + out, err := Run([]string{"describe", "--tags", "--abbrev=0", current + "^"}) + if err != nil { + return "", err + } + return strings.TrimSpace(out), nil +} + +// LogSince returns formatted commit log since the given ref (exactly matches the todo spec). +func LogSince(since string) (string, error) { + args := []string{"log", "--pretty=format:%s%n%b%n---", since + "..HEAD"} + return Run(args) +}