changelog generator & diff tool stormlightlabs.github.io/git-storm/
changelog changeset markdown golang git
at main 10 kB view raw
1/* 2USAGE 3 4 storm unreleased <subcommand> [options] 5 6SUBCOMMANDS 7 8 add Add a new unreleased change entry 9 list List all unreleased changes 10 review Review unreleased changes interactively 11 partial Create entry linked to a specific commit 12 13USAGE 14 15 storm unreleased add [options] 16 17FLAGS 18 19 --type <type> Change type (added, changed, fixed, removed, security) 20 --scope <scope> Optional subsystem or module name 21 --summary <text> Short description of the change 22 --repo <path> Path to the repository (default: .) 23 24USAGE 25 26 storm unreleased list [options] 27 28FLAGS 29 30 --json Output as JSON 31 --repo <path> Path to the repository (default: .) 32 33USAGE 34 35 storm unreleased review [options] 36 37FLAGS 38 39 --repo <path> Path to the repository (default: .) 40 --output <file> Optional file to export reviewed notes 41 42USAGE 43 44 storm unreleased partial <commit-ref> [options] 45 46FLAGS 47 48 --type <type> Override change type (auto-detected from commit message) 49 --summary <text> Override summary (auto-detected from commit message) 50 --scope <scope> Optional subsystem or module name 51 --repo <path> Path to the repository (default: .) 52*/ 53package main 54 55import ( 56 "encoding/json" 57 "fmt" 58 "slices" 59 "strings" 60 61 tea "github.com/charmbracelet/bubbletea" 62 "github.com/go-git/go-git/v6" 63 "github.com/go-git/go-git/v6/plumbing" 64 "github.com/spf13/cobra" 65 "github.com/stormlightlabs/git-storm/internal/changeset" 66 "github.com/stormlightlabs/git-storm/internal/gitlog" 67 "github.com/stormlightlabs/git-storm/internal/style" 68 "github.com/stormlightlabs/git-storm/internal/tty" 69 "github.com/stormlightlabs/git-storm/internal/ui" 70) 71 72func unreleasedCmd() *cobra.Command { 73 var ( 74 changeType string 75 scope string 76 summary string 77 outputJSON bool 78 ) 79 80 changesDir := ".changes" 81 validTypes := []string{"added", "changed", "fixed", "removed", "security"} 82 83 add := &cobra.Command{ 84 Use: "add", 85 Short: "Add a new unreleased change entry", 86 Long: `Creates a new .changes/<date>-<summary>.md file with the specified type, 87scope, and summary.`, 88 RunE: func(cmd *cobra.Command, args []string) error { 89 if !slices.Contains(validTypes, changeType) { 90 return fmt.Errorf("invalid type %q: must be one of %s", changeType, strings.Join(validTypes, ", ")) 91 } 92 93 if filePath, err := changeset.Write(changesDir, changeset.Entry{ 94 Type: changeType, 95 Scope: scope, 96 Summary: summary, 97 }); err != nil { 98 return fmt.Errorf("failed to create changelog entry: %w", err) 99 } else { 100 style.Addedf("Created %s", filePath) 101 return nil 102 } 103 }, 104 } 105 add.Flags().StringVar(&changeType, "type", "", "Type of change (added, changed, fixed, removed, security)") 106 add.Flags().StringVar(&scope, "scope", "", "Optional scope or subsystem name") 107 add.Flags().StringVar(&summary, "summary", "", "Short summary of the change") 108 add.MarkFlagRequired("type") 109 add.MarkFlagRequired("summary") 110 111 list := &cobra.Command{ 112 Use: "list", 113 Short: "List all unreleased changes", 114 Long: "Prints all pending .changes entries to stdout. Supports JSON output.", 115 RunE: func(cmd *cobra.Command, args []string) error { 116 entries, err := changeset.List(changesDir) 117 if err != nil { 118 return fmt.Errorf("failed to list changelog entries: %w", err) 119 } 120 121 if len(entries) == 0 { 122 style.Println("No unreleased changes found") 123 return nil 124 } 125 126 if outputJSON { 127 jsonBytes, err := json.MarshalIndent(entries, "", " ") 128 if err != nil { 129 return fmt.Errorf("failed to marshal entries to JSON: %w", err) 130 } 131 fmt.Println(string(jsonBytes)) 132 return nil 133 } 134 135 style.Headlinef("Found %d unreleased change(s):", len(entries)) 136 style.Newline() 137 138 for _, e := range entries { 139 displayEntry(e) 140 } 141 142 return nil 143 }, 144 } 145 list.Flags().BoolVar(&outputJSON, "json", false, "Output results as JSON") 146 147 review := &cobra.Command{ 148 Use: "review", 149 Short: "Review unreleased changes interactively", 150 Long: `Launches an interactive Bubble Tea TUI to review, edit, or categorize 151unreleased entries before final release.`, 152 RunE: func(cmd *cobra.Command, args []string) error { 153 if !tty.IsInteractive() { 154 return tty.ErrorInteractiveRequired("storm unreleased review", []string{ 155 "Use 'storm unreleased list' to view entries in plain text", 156 "Use 'storm unreleased list --json' for JSON output", 157 }) 158 } 159 160 entries, err := changeset.List(changesDir) 161 if err != nil { 162 return fmt.Errorf("failed to list changelog entries: %w", err) 163 } 164 165 if len(entries) == 0 { 166 style.Println("No unreleased changes found") 167 return nil 168 } 169 170 model := ui.NewChangesetReviewModel(entries) 171 p := tea.NewProgram(model, tea.WithAltScreen()) 172 173 finalModel, err := p.Run() 174 if err != nil { 175 return fmt.Errorf("failed to run review TUI: %w", err) 176 } 177 178 reviewModel, ok := finalModel.(ui.ChangesetReviewModel) 179 if !ok { 180 return fmt.Errorf("unexpected model type") 181 } 182 183 if reviewModel.IsCancelled() { 184 style.Headline("Review cancelled") 185 return nil 186 } 187 188 items := reviewModel.GetReviewedItems() 189 deleteCount := 0 190 editCount := 0 191 192 for _, item := range items { 193 if item.Action == ui.ActionDelete { 194 if err := changeset.Delete(changesDir, item.Entry.Filename); err != nil { 195 return fmt.Errorf("failed to delete %s: %w", item.Entry.Filename, err) 196 } 197 deleteCount++ 198 style.Successf("Deleted: %s", item.Entry.Filename) 199 } 200 } 201 202 for _, item := range items { 203 if item.Action == ui.ActionEdit { 204 editorModel := ui.NewEntryEditorModel(item.Entry) 205 p := tea.NewProgram(editorModel, tea.WithAltScreen()) 206 207 finalModel, err := p.Run() 208 if err != nil { 209 return fmt.Errorf("failed to run editor TUI: %w", err) 210 } 211 212 editor, ok := finalModel.(ui.EntryEditorModel) 213 if !ok { 214 return fmt.Errorf("unexpected model type") 215 } 216 217 if editor.IsCancelled() { 218 style.Warningf("Skipped editing: %s", item.Entry.Filename) 219 continue 220 } 221 222 if editor.IsConfirmed() { 223 editedEntry := editor.GetEditedEntry() 224 if err := changeset.Update(changesDir, item.Entry.Filename, editedEntry); err != nil { 225 return fmt.Errorf("failed to update %s: %w", item.Entry.Filename, err) 226 } 227 editCount++ 228 style.Successf("Updated: %s", item.Entry.Filename) 229 } 230 } 231 } 232 233 if deleteCount == 0 && editCount == 0 { 234 style.Headline("No changes requested") 235 return nil 236 } 237 238 style.Headlinef("Review completed: %d deleted, %d edited", deleteCount, editCount) 239 return nil 240 }, 241 } 242 243 partial := &cobra.Command{ 244 Use: "partial <commit-ref>", 245 Short: "Create entry linked to a specific commit", 246 Long: `Creates a new .changes/<sha7>.<type>.md file based on the specified commit. 247Auto-detects type and summary from conventional commit format, with optional overrides.`, 248 Args: cobra.ExactArgs(1), 249 RunE: func(cmd *cobra.Command, args []string) error { 250 commitRef := args[0] 251 252 repo, err := git.PlainOpen(repoPath) 253 if err != nil { 254 return fmt.Errorf("failed to open repository: %w", err) 255 } 256 257 hash, err := repo.ResolveRevision(plumbing.Revision(commitRef)) 258 if err != nil { 259 return fmt.Errorf("failed to resolve commit ref %q: %w", commitRef, err) 260 } 261 262 commit, err := repo.CommitObject(*hash) 263 if err != nil { 264 return fmt.Errorf("failed to get commit object: %w", err) 265 } 266 267 parser := &gitlog.ConventionalParser{} 268 subject := commit.Message 269 body := "" 270 lines := strings.Split(commit.Message, "\n") 271 if len(lines) > 0 { 272 subject = lines[0] 273 if len(lines) > 1 { 274 body = strings.Join(lines[1:], "\n") 275 } 276 } 277 278 meta, err := parser.Parse(hash.String(), subject, body, commit.Author.When) 279 if err != nil { 280 return fmt.Errorf("failed to parse commit message: %w", err) 281 } 282 283 category := parser.Categorize(meta) 284 285 if changeType != "" { 286 if !slices.Contains(validTypes, changeType) { 287 return fmt.Errorf("invalid type %q: must be one of %s", changeType, strings.Join(validTypes, ", ")) 288 } 289 category = changeType 290 } else if category == "" { 291 return fmt.Errorf("could not auto-detect change type from commit message, please specify --type") 292 } 293 294 entrySummary := meta.Description 295 if summary != "" { 296 entrySummary = summary 297 } 298 299 if scope != "" { 300 meta.Scope = scope 301 } 302 303 sha7 := hash.String()[:7] 304 filename := fmt.Sprintf("%s.%s.md", sha7, category) 305 filePath := changesDir + "/" + filename 306 307 entry := changeset.Entry{ 308 Type: category, 309 Scope: meta.Scope, 310 Summary: entrySummary, 311 Breaking: meta.Breaking, 312 CommitHash: hash.String(), 313 } 314 315 if _, err := changeset.WritePartial(changesDir, filename, entry); err != nil { 316 return fmt.Errorf("failed to create changelog entry: %w", err) 317 } 318 319 style.Addedf("Created %s", filePath) 320 return nil 321 }, 322 } 323 partial.Flags().StringVar(&changeType, "type", "", "Override change type (auto-detected from commit)") 324 partial.Flags().StringVar(&scope, "scope", "", "Optional scope or subsystem name") 325 partial.Flags().StringVar(&summary, "summary", "", "Override summary (auto-detected from commit)") 326 327 root := &cobra.Command{ 328 Use: "unreleased", 329 Short: "Manage unreleased changes (.changes directory)", 330 Long: `Work with unreleased change notes. Supports adding, listing, 331and reviewing pending entries before release.`, 332 } 333 root.AddCommand(add, list, review, partial) 334 return root 335} 336 337// displayEntry formats and prints a single changelog entry with color-coded type. 338func displayEntry(e changeset.EntryWithFile) { 339 var typeLabel string 340 switch e.Entry.Type { 341 case "added": 342 typeLabel = style.StyleAdded.Render(fmt.Sprintf("[%s]", e.Entry.Type)) 343 case "changed": 344 typeLabel = style.StyleChanged.Render(fmt.Sprintf("[%s]", e.Entry.Type)) 345 case "fixed": 346 typeLabel = style.StyleFixed.Render(fmt.Sprintf("[%s]", e.Entry.Type)) 347 case "removed": 348 typeLabel = style.StyleRemoved.Render(fmt.Sprintf("[%s]", e.Entry.Type)) 349 case "security": 350 typeLabel = style.StyleSecurity.Render(fmt.Sprintf("[%s]", e.Entry.Type)) 351 default: 352 typeLabel = fmt.Sprintf("[%s]", e.Entry.Type) 353 } 354 355 var scopePart string 356 if e.Entry.Scope != "" { 357 scopePart = fmt.Sprintf("(%s) ", e.Entry.Scope) 358 } 359 360 style.Println("%s %s%s", typeLabel, scopePart, e.Entry.Summary) 361 style.Println(" File: %s", e.Filename) 362 if e.Entry.Breaking { 363 style.Println(" Breaking: %s\n", style.StyleRemoved.Render("YES")) 364 } 365 style.Newline() 366}