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
This commit is contained in:
parent
c5bec5ce43
commit
ce5367c3a7
143
cmd/changelog.go
Normal file
143
cmd/changelog.go
Normal file
@ -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)
|
||||
}
|
||||
106
cmd/changelog_test.go
Normal file
106
cmd/changelog_test.go
Normal file
@ -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)
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user