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) }