diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..7321014 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,135 @@ +# Roadmap + +## Core CLI + +The foundation CLI structure with core commands. + +### Commands + +- [x] `storm version` - Print version information +- [x] `storm generate` - Generate changelog entries from Git commits + - [x] Parse commit range (from/to refs) + - [x] Support `--since` flag + - [x] Support `--interactive` flag for TUI selection + - [x] Parse conventional commits + - [x] Write entries to `.changes/` + - [ ] Deduplication logic (see TODO in generate.go) +- [x] `storm unreleased` - Manage unreleased changes + - [x] `unreleased add` - Create new entry + - [x] `unreleased list` - Display entries (text and JSON) + - [x] `unreleased review` - Interactive TUI review + - [ ] Implement delete action from review + - [ ] Implement edit action from review +- [x] `storm release` - Promote unreleased changes to CHANGELOG + - [x] Read all `.changes/*.md` files + - [x] Merge into `CHANGELOG.md` + - [x] Create version header with date + - [x] Clear `.changes/` directory with `--clear-changes` flag + - [x] Optional date override with `--date` flag + - [x] Generate GitHub comparison links automatically + - [x] Dry-run mode + - [ ] Optional Git tag creation (Phase 7) +- [x] `storm diff`: display inline diffs between refs with support for file filtering, + context expansion, and multiple view modes. + +## Git Integration and Commit Parsing + +- [x] Core gitlog utilities for parsing refs, retrieving commits and file contents, and + categorizing conventional commits by type and change significance. + +## Diff Engine and Styling + +- [x] Diff package implements the Myers diff algorithm with split and unified rendering, + compressed unchanged sections, and an iceberg-themed color palette for styled visual + output. + +## `.changes` Storage and Parsing + +Local storage for unreleased changelog entries. + +### Tasks + +- [x] Define `Entry` struct with YAML frontmatter +- [x] Implement `changeset.Write(dir, entry)` + - [x] Generate unique filenames (timestamp-based) + - [x] Write YAML frontmatter + - [x] Create `.changes/` directory if missing +- [x] Implement `changeset.List(dir)` + - [x] Parse YAML frontmatter + - [x] Return `EntryWithFile` structs +- [ ] Implement diff-based deduplication + - [ ] Compute diff hash for commits + - [ ] Load existing entries by hash + - [ ] Detect rebased commits (same diff, different hash) + - [ ] Add `--update-rebased`, `--skip-rebased`, `--warn-rebased` flags + +## TUI + +- [x] Delivered Bubble Tea UIs for selecting commits, reviewing unreleased changes, and + interactively viewing multi-file diffs with full keyboard-driven navigation and view + toggles. + +## Keep a Changelog Writer + +- [x] Adds a full changelog pipeline that parses the existing file, builds and writes +new releases, and validates dates/sections to strictly match the Keep a Changelog +[spec](https://keepachangelog.com/en/1.1.0/), including autogenerated comparison links. + +## Phase 7: Git Tagging and CI Integration + +Repository tagging and automation-friendly features. + +### Tasks + +- [ ] Implement Git tagging in `release` command + - [ ] Create annotated tag with version + - [ ] Include release notes in tag message + - [ ] Validate tag doesn't already exist + - [ ] Support `--tag` flag +- [ ] Add JSON output modes for all commands + - [x] `unreleased list --json` (implemented) + - [ ] `generate --output-json` + - [ ] `release --output-json` +- [x] Add `--dry-run` support + - [x] `release --dry-run` - implemented + - [x] Show what would be written without writing + - [x] Display preview of CHANGELOG changes with styled output +- [ ] Non-TTY environment handling + - [ ] Detect TTY availability + - [ ] Fallback to non-interactive mode + - [ ] CI-friendly error messages +- [ ] Add pre-commit hook examples + - [ ] Validate commit message format + - [ ] Ensure `.changes/` entries exist for features +- [ ] Create GitHub Actions workflow examples + - [ ] Auto-release on version tag + - [ ] Validate CHANGELOG on PR + +## Testing Strategy + +### Current Status + +- [x] Test utilities package - internal/testutils/ +- [x] Unit tests for changelog package - internal/changelog/changelog_test.go +- [ ] Unit tests for diff engine +- [ ] Unit tests for Git integration (in-memory repos) +- [ ] Golden files for diff output +- [ ] Golden files for changelog output +- [ ] Bubble Tea program testing + +### Planned Test Coverage + +- [ ] `internal/diff` - Myers algorithm correctness +- [ ] `internal/gitlog` - Commit parsing and range queries +- [x] `internal/changeset` - File I/O and YAML parsing +- [x] `internal/changelog` - Keep a Changelog formatting (13 test cases, all passing) +- [ ] `cmd/generate` - End-to-end commit to entry flow +- [ ] `cmd/unreleased` - Entry management +- [ ] `cmd/release` - Changelog generation and tagging + +## Notes + +- No shell calls to `git` - all operations via `go-git` +- Conventional commits are parsed but not enforced +- TUI sessions degrade gracefully in non-TTY environments (to be implemented) +- All output follows Keep a Changelog v1.1.0 specification diff --git a/cmd/main.go b/cmd/main.go index 389f915..fb62acb 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -15,19 +15,7 @@ var ( output string ) -var ( - changeType string - scope string - summary string - outputJSON bool -) - -var ( - releaseVersion string - tagRelease bool - dryRun bool -) - +// TODO: use ldflags const versionString string = "0.1.0-dev" func versionCmd() *cobra.Command { @@ -41,27 +29,6 @@ func versionCmd() *cobra.Command { } } -func releaseCmd() *cobra.Command { - c := &cobra.Command{ - Use: "release", - Short: "Promote unreleased changes into a new changelog version", - Long: `Merges all .changes entries into CHANGELOG.md under a new version header. -Optionally creates a Git tag and clears the .changes directory.`, - RunE: func(cmd *cobra.Command, args []string) error { - fmt.Println("release command not implemented") - fmt.Printf("version=%v tag=%v dry-run=%v\n", releaseVersion, tagRelease, dryRun) - return nil - }, - } - - c.Flags().StringVar(&releaseVersion, "version", "", "Semantic version for the new release (e.g., 1.3.0)") - c.Flags().BoolVar(&tagRelease, "tag", false, "Create a Git tag after release") - c.Flags().BoolVar(&dryRun, "dry-run", false, "Preview changes without writing files") - c.MarkFlagRequired("version") - - return c -} - func main() { ctx := context.Background() root := &cobra.Command{ diff --git a/cmd/release.go b/cmd/release.go new file mode 100644 index 0000000..aaff09e --- /dev/null +++ b/cmd/release.go @@ -0,0 +1,175 @@ +/* +USAGE + + storm release --version [options] + +FLAGS + + --version Semantic version for the new release (required) + --date Release date (default: today) + --clear-changes Delete .changes/*.md files after successful release + --dry-run Preview changes without writing files + --tag Create a Git tag after release (not implemented) + --repo Path to the Git repository (default: .) + --output Output changelog file path (default: CHANGELOG.md) +*/ +package main + +import ( + "fmt" + "os" + "path/filepath" + "time" + + "github.com/spf13/cobra" + "github.com/stormlightlabs/git-storm/internal/changelog" + "github.com/stormlightlabs/git-storm/internal/changeset" + "github.com/stormlightlabs/git-storm/internal/style" +) + +func releaseCmd() *cobra.Command { + var ( + version string + date string + clearChanges bool + dryRun bool + tag bool + ) + + c := &cobra.Command{ + Use: "release", + Short: "Promote unreleased changes into a new changelog version", + Long: `Merges all .changes entries into CHANGELOG.md under a new version header. +Optionally creates a Git tag and clears the .changes directory.`, + RunE: func(cmd *cobra.Command, args []string) error { + if err := changelog.ValidateVersion(version); err != nil { + return err + } + + releaseDate := date + if releaseDate == "" { + releaseDate = time.Now().Format("2006-01-02") + } else { + if err := changelog.ValidateDate(releaseDate); err != nil { + return err + } + } + + style.Headlinef("Preparing release %s (%s)", version, releaseDate) + style.Newline() + + changesDir := ".changes" + entries, err := changeset.List(changesDir) + if err != nil { + return fmt.Errorf("failed to read .changes directory: %w", err) + } + + if len(entries) == 0 { + return fmt.Errorf("no unreleased changes found in %s", changesDir) + } + + style.Println("Found %d unreleased entries", len(entries)) + style.Newline() + + var entryList []changeset.Entry + for _, e := range entries { + entryList = append(entryList, e.Entry) + } + + newVersion, err := changelog.Build(entryList, version, releaseDate) + if err != nil { + return fmt.Errorf("failed to build version: %w", err) + } + + changelogPath := filepath.Join(repoPath, output) + existingChangelog, err := changelog.Parse(changelogPath) + if err != nil { + return fmt.Errorf("failed to parse existing changelog: %w", err) + } + + changelog.Merge(existingChangelog, newVersion) + + if dryRun { + style.Headline("Dry-run mode: Preview of CHANGELOG.md") + style.Newline() + displayVersionPreview(newVersion) + style.Newline() + style.Println("No files were modified (--dry-run)") + return nil + } + + if err := changelog.Write(changelogPath, existingChangelog, repoPath); err != nil { + return fmt.Errorf("failed to write CHANGELOG.md: %w", err) + } + + style.Addedf("✓ Updated %s", changelogPath) + + if clearChanges { + deletedCount := 0 + for _, entry := range entries { + filePath := filepath.Join(changesDir, entry.Filename) + if err := os.Remove(filePath); err != nil { + style.Println("Warning: failed to delete %s: %v", filePath, err) + continue + } + deletedCount++ + } + style.Println("✓ Deleted %d entry files from %s", deletedCount, changesDir) + } + + style.Newline() + style.Headlinef("Release %s completed successfully", version) + + if tag { + style.Newline() + style.Println("Note: --tag flag is not yet implemented (Phase 7)") + } + + return nil + }, + } + + c.Flags().StringVar(&version, "version", "", "Semantic version for the new release (e.g., 1.3.0)") + c.Flags().StringVar(&date, "date", "", "Release date in YYYY-MM-DD format (default: today)") + c.Flags().BoolVar(&clearChanges, "clear-changes", false, "Delete .changes/*.md files after successful release") + c.Flags().BoolVar(&dryRun, "dry-run", false, "Preview changes without writing files") + c.Flags().BoolVar(&tag, "tag", false, "Create a Git tag after release (not implemented)") + c.MarkFlagRequired("version") + + return c +} + +// displayVersionPreview shows a formatted preview of the version being released. +func displayVersionPreview(version *changelog.Version) { + fmt.Printf("## [%s] - %s\n\n", version.Number, version.Date) + + for i, section := range version.Sections { + if i > 0 { + fmt.Println() + } + + var sectionTitle string + switch section.Type { + case "added": + sectionTitle = style.StyleAdded.Render("### Added") + case "changed": + sectionTitle = style.StyleChanged.Render("### Changed") + case "deprecated": + sectionTitle = "### Deprecated" + case "removed": + sectionTitle = style.StyleRemoved.Render("### Removed") + case "fixed": + sectionTitle = style.StyleFixed.Render("### Fixed") + case "security": + sectionTitle = style.StyleSecurity.Render("### Security") + default: + sectionTitle = fmt.Sprintf("### %s", section.Type) + } + fmt.Println(sectionTitle) + fmt.Println() + + for _, entry := range section.Entries { + fmt.Printf("- %s\n", entry) + } + } +} diff --git a/cmd/unreleased.go b/cmd/unreleased.go index 2c27928..cdbc8dc 100644 --- a/cmd/unreleased.go +++ b/cmd/unreleased.go @@ -54,31 +54,36 @@ import ( ) func unreleasedCmd() *cobra.Command { + var ( + changeType string + scope string + summary string + outputJSON bool + ) + + changesDir := ".changes" + validTypes := []string{"added", "changed", "fixed", "removed", "security"} + add := &cobra.Command{ Use: "add", Short: "Add a new unreleased change entry", Long: `Creates a new .changes/-.md file with the specified type, scope, and summary.`, RunE: func(cmd *cobra.Command, args []string) error { - validTypes := []string{"added", "changed", "fixed", "removed", "security"} if !slices.Contains(validTypes, changeType) { return fmt.Errorf("invalid type %q: must be one of %s", changeType, strings.Join(validTypes, ", ")) } - entry := changeset.Entry{ + if filePath, err := changeset.Write(changesDir, changeset.Entry{ Type: changeType, Scope: scope, Summary: summary, - } - - changesDir := ".changes" - filePath, err := changeset.Write(changesDir, entry) - if err != nil { + }); err != nil { return fmt.Errorf("failed to create changelog entry: %w", err) + } else { + style.Addedf("Created %s", filePath) + return nil } - - style.Addedf("Created %s", filePath) - return nil }, } add.Flags().StringVar(&changeType, "type", "", "Type of change (added, changed, fixed, removed, security)") @@ -92,7 +97,6 @@ scope, and summary.`, Short: "List all unreleased changes", Long: "Prints all pending .changes entries to stdout. Supports JSON output.", RunE: func(cmd *cobra.Command, args []string) error { - changesDir := ".changes" entries, err := changeset.List(changesDir) if err != nil { return fmt.Errorf("failed to list changelog entries: %w", err) @@ -130,7 +134,6 @@ scope, and summary.`, Long: `Launches an interactive Bubble Tea TUI to review, edit, or categorize unreleased entries before final release.`, RunE: func(cmd *cobra.Command, args []string) error { - changesDir := ".changes" entries, err := changeset.List(changesDir) if err != nil { return fmt.Errorf("failed to list changelog entries: %w", err) @@ -179,7 +182,6 @@ unreleased entries before final release.`, style.Headlinef("Review completed: %d to delete, %d to edit", deleteCount, editCount) style.Println("Note: Delete and edit actions are not yet implemented") - return nil }, } @@ -191,7 +193,6 @@ unreleased entries before final release.`, and reviewing pending entries before release.`, } root.AddCommand(add, list, review) - return root } diff --git a/internal/changelog/changelog.go b/internal/changelog/changelog.go new file mode 100644 index 0000000..1495ab4 --- /dev/null +++ b/internal/changelog/changelog.go @@ -0,0 +1,406 @@ +// Package changelog implements Keep a Changelog parsing, building, and writing. +// +// It generates CHANGELOG.md files compliant with https://keepachangelog.com/en/1.1.0/ +package changelog + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "time" + + "github.com/go-git/go-git/v6" + "github.com/stormlightlabs/git-storm/internal/changeset" +) + +// Changelog represents the entire CHANGELOG.md file structure. +type Changelog struct { + Header string // Preamble text before versions + Versions []Version // All versions in chronological order (newest first) + Links []string // Version comparison links at the bottom +} + +// Version represents a single version section in the changelog. +type Version struct { + Number string // Semantic version (e.g., "1.2.0") + Date string // ISO date (YYYY-MM-DD) or "Unreleased" + Sections []Section // Category sections (Added, Changed, etc.) +} + +// Section represents a category section within a version. +type Section struct { + Type string // added, changed, deprecated, removed, fixed, security + Entries []string // Individual entries without leading dashes +} + +// sectionOrder defines the Keep a Changelog section ordering. +var sectionOrder = []string{"added", "changed", "deprecated", "removed", "fixed", "security"} + +// sectionTitles maps internal types to Keep a Changelog titles. +var sectionTitles = map[string]string{ + "added": "Added", + "changed": "Changed", + "deprecated": "Deprecated", + "removed": "Removed", + "fixed": "Fixed", + "security": "Security", +} + +// versionHeaderRegex matches version headers like "## [1.2.0] - 2025-01-15" or "## [Unreleased]" +var versionHeaderRegex = regexp.MustCompile(`^##\s+\[([^\]]+)\](?:\s+-\s+(.+))?$`) + +// sectionHeaderRegex matches section headers like "### Added" +var sectionHeaderRegex = regexp.MustCompile(`^###\s+(.+)$`) + +// entryRegex matches changelog entries like "- Entry text" +var entryRegex = regexp.MustCompile(`^-\s+(.+)$`) + +// semanticVersionRegex validates semantic versioning (X.Y.Z) +var semanticVersionRegex = regexp.MustCompile(`^\d+\.\d+\.\d+$`) + +// linkRegex matches comparison links like "[1.2.0]: https://..." +var linkRegex = regexp.MustCompile(`^\[([^\]]+)\]:\s+(.+)$`) + +// Parse reads and parses an existing CHANGELOG.md file. +// Returns an empty Changelog with default header if the file doesn't exist. +func Parse(path string) (*Changelog, error) { + file, err := os.Open(path) + if os.IsNotExist(err) { + return newEmptyChangelog(), nil + } + if err != nil { + return nil, fmt.Errorf("failed to open changelog: %w", err) + } + defer file.Close() + + changelog := &Changelog{} + scanner := bufio.NewScanner(file) + + var headerLines []string + var currentVersion *Version + var currentSection *Section + inLinks := false + + for scanner.Scan() { + line := scanner.Text() + + if linkMatch := linkRegex.FindStringSubmatch(line); linkMatch != nil { + inLinks = true + changelog.Links = append(changelog.Links, line) + continue + } + + if inLinks { + if strings.TrimSpace(line) != "" { + changelog.Links = append(changelog.Links, line) + } + continue + } + + if versionMatch := versionHeaderRegex.FindStringSubmatch(line); versionMatch != nil { + if currentVersion != nil { + if currentSection != nil && len(currentSection.Entries) > 0 { + currentVersion.Sections = append(currentVersion.Sections, *currentSection) + } + changelog.Versions = append(changelog.Versions, *currentVersion) + } + + currentVersion = &Version{ + Number: versionMatch[1], + } + if len(versionMatch) > 2 && versionMatch[2] != "" { + currentVersion.Date = versionMatch[2] + } else { + currentVersion.Date = "Unreleased" + } + currentSection = nil + continue + } + + if sectionMatch := sectionHeaderRegex.FindStringSubmatch(line); sectionMatch != nil { + if currentVersion != nil { + if currentSection != nil && len(currentSection.Entries) > 0 { + currentVersion.Sections = append(currentVersion.Sections, *currentSection) + } + + sectionTitle := sectionMatch[1] + sectionType := findSectionType(sectionTitle) + currentSection = &Section{ + Type: sectionType, + Entries: []string{}, + } + } + continue + } + + if entryMatch := entryRegex.FindStringSubmatch(line); entryMatch != nil { + if currentSection != nil { + currentSection.Entries = append(currentSection.Entries, entryMatch[1]) + } + continue + } + + if currentVersion == nil { + headerLines = append(headerLines, line) + } + } + + if currentVersion != nil { + if currentSection != nil && len(currentSection.Entries) > 0 { + currentVersion.Sections = append(currentVersion.Sections, *currentSection) + } + changelog.Versions = append(changelog.Versions, *currentVersion) + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("failed to read changelog: %w", err) + } + + changelog.Header = strings.TrimSpace(strings.Join(headerLines, "\n")) + if changelog.Header == "" { + changelog.Header = defaultHeader() + } + + return changelog, nil +} + +// Build creates a new Version from changeset entries. +// +// Entries are grouped by type, sorted, and formatted with breaking change prefixes. +func Build(entries []changeset.Entry, version, date string) (*Version, error) { + if err := ValidateVersion(version); err != nil { + return nil, err + } + + if err := ValidateDate(date); err != nil { + return nil, err + } + + grouped := make(map[string][]string) + for _, entry := range entries { + text := entry.Summary + if entry.Scope != "" { + text = fmt.Sprintf("**%s:** %s", entry.Scope, text) + } + if entry.Breaking { + text = fmt.Sprintf("**BREAKING:** %s", text) + } + + grouped[entry.Type] = append(grouped[entry.Type], text) + } + + for typ := range grouped { + sort.Strings(grouped[typ]) + } + + // Build sections in Keep a Changelog order + var sections []Section + for _, typ := range sectionOrder { + if entryList, exists := grouped[typ]; exists && len(entryList) > 0 { + sections = append(sections, Section{ + Type: typ, + Entries: entryList, + }) + } + } + + return &Version{ + Number: version, + Date: date, + Sections: sections, + }, nil +} + +// Merge inserts a new version into the changelog at the top (below Unreleased if present). +func Merge(changelog *Changelog, version *Version) { + insertIndex := 0 + if len(changelog.Versions) > 0 && strings.ToLower(changelog.Versions[0].Number) == "unreleased" { + insertIndex = 1 + } + + versions := make([]Version, 0, len(changelog.Versions)+1) + versions = append(versions, changelog.Versions[:insertIndex]...) + versions = append(versions, *version) + versions = append(versions, changelog.Versions[insertIndex:]...) + changelog.Versions = versions +} + +// Write writes the changelog to a file with proper Keep a Changelog formatting. +// +// Generates version comparison links if a git remote is available. +func Write(path string, changelog *Changelog, repoPath string) error { + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return fmt.Errorf("failed to create directory: %w", err) + } + + file, err := os.Create(path) + if err != nil { + return fmt.Errorf("failed to create changelog: %w", err) + } + defer file.Close() + + w := bufio.NewWriter(file) + defer w.Flush() + + if changelog.Header != "" { + fmt.Fprintf(w, "%s\n\n", changelog.Header) + } + + for i, version := range changelog.Versions { + if i > 0 { + fmt.Fprintln(w) + } + + if version.Date == "" || strings.ToLower(version.Date) == "unreleased" { + fmt.Fprintf(w, "## [%s]\n\n", version.Number) + } else { + fmt.Fprintf(w, "## [%s] - %s\n\n", version.Number, version.Date) + } + + for j, section := range version.Sections { + if j > 0 { + fmt.Fprintln(w) + } + + title := sectionTitles[section.Type] + if title == "" { + if len(section.Type) > 0 { + title = strings.ToUpper(section.Type[:1]) + section.Type[1:] + } else { + title = section.Type + } + } + fmt.Fprintf(w, "### %s\n\n", title) + + for _, entry := range section.Entries { + fmt.Fprintf(w, "- %s\n", entry) + } + } + } + + links, err := GenerateLinks(repoPath, changelog.Versions) + if err == nil && len(links) > 0 { + fmt.Fprintln(w) + for _, link := range links { + fmt.Fprintln(w, link) + } + } else if len(changelog.Links) > 0 { + fmt.Fprintln(w) + for _, link := range changelog.Links { + fmt.Fprintln(w, link) + } + } + + return nil +} + +// GenerateLinks creates version comparison links for GitHub repositories. +func GenerateLinks(repoPath string, versions []Version) ([]string, error) { + repo, err := git.PlainOpen(repoPath) + if err != nil { + return nil, fmt.Errorf("failed to open repository: %w", err) + } + + remote, err := repo.Remote("origin") + if err != nil { + return nil, fmt.Errorf("no origin remote configured: %w", err) + } + + if len(remote.Config().URLs) == 0 { + return nil, fmt.Errorf("no remote URL configured") + } + + remoteURL := remote.Config().URLs[0] + baseURL := parseGitHubURL(remoteURL) + if baseURL == "" { + return nil, fmt.Errorf("not a GitHub repository") + } + + var links []string + for i, version := range versions { + var link string + if strings.ToLower(version.Number) == "unreleased" { + if len(versions) > 1 { + link = fmt.Sprintf("[Unreleased]: %s/compare/v%s...HEAD", baseURL, versions[1].Number) + } else { + link = fmt.Sprintf("[Unreleased]: %s/compare/HEAD", baseURL) + } + } else { + if i+1 < len(versions) && strings.ToLower(versions[i+1].Number) != "unreleased" { + link = fmt.Sprintf("[%s]: %s/compare/v%s...v%s", version.Number, baseURL, versions[i+1].Number, version.Number) + } else { + link = fmt.Sprintf("[%s]: %s/releases/tag/v%s", version.Number, baseURL, version.Number) + } + } + links = append(links, link) + } + + return links, nil +} + +// ValidateVersion checks if a version string follows semantic versioning (X.Y.Z). +func ValidateVersion(version string) error { + if !semanticVersionRegex.MatchString(version) { + return fmt.Errorf("invalid semantic version '%s': must be X.Y.Z format (e.g., 1.2.0)", version) + } + return nil +} + +// ValidateDate checks if a date string follows ISO 8601 format (YYYY-MM-DD). +func ValidateDate(date string) error { + _, err := time.Parse("2006-01-02", date) + if err != nil { + return fmt.Errorf("invalid date '%s': must be YYYY-MM-DD format", date) + } + return nil +} + +// parseGitHubURL extracts the base GitHub URL from a git remote URL. +// +// Handles both HTTPS and SSH formats. +func parseGitHubURL(remoteURL string) string { + remoteURL = strings.TrimSuffix(remoteURL, ".git") + + if strings.HasPrefix(remoteURL, "https://github.com/") { + return remoteURL + } + + if parts, ok := strings.CutPrefix(remoteURL, "git@github.com:"); ok { + return "https://github.com/" + parts + } + return "" +} + +// findSectionType converts a section title to its internal type. +func findSectionType(title string) string { + titleLower := strings.ToLower(strings.TrimSpace(title)) + for typ, standardTitle := range sectionTitles { + if strings.ToLower(standardTitle) == titleLower { + return typ + } + } + return titleLower +} + +// newEmptyChangelog creates a changelog with default header and empty versions. +func newEmptyChangelog() *Changelog { + return &Changelog{ + Header: defaultHeader(), + Versions: []Version{}, + Links: []string{}, + } +} + +// defaultHeader returns the standard Keep a Changelog header. +func defaultHeader() string { + return `# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).` +} diff --git a/internal/changelog/changelog_test.go b/internal/changelog/changelog_test.go new file mode 100644 index 0000000..fa4150d --- /dev/null +++ b/internal/changelog/changelog_test.go @@ -0,0 +1,536 @@ +package changelog + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stormlightlabs/git-storm/internal/changeset" +) + +func TestParse(t *testing.T) { + tests := []struct { + name string + content string + wantVersionCount int + wantFirstVersion string + wantFirstDate string + }{ + { + name: "empty file returns default header", + content: `# Changelog + +All notable changes to this project will be documented in this file.`, + wantVersionCount: 0, + }, + { + name: "single version with sections", + content: `# Changelog + +## [1.0.0] - 2025-01-15 + +### Added +- New feature A +- New feature B + +### Fixed +- Bug fix C +`, + wantVersionCount: 1, + wantFirstVersion: "1.0.0", + wantFirstDate: "2025-01-15", + }, + { + name: "multiple versions", + content: `# Changelog + +## [Unreleased] + +## [1.2.0] - 2025-01-15 + +### Added +- Feature X + +## [1.1.0] - 2025-01-10 + +### Fixed +- Bug Y +`, + wantVersionCount: 3, + wantFirstVersion: "Unreleased", + wantFirstDate: "Unreleased", + }, + { + name: "version with comparison links", + content: `# Changelog + +## [1.0.0] - 2025-01-15 + +### Added +- Feature A + +[1.0.0]: https://github.com/user/repo/releases/tag/v1.0.0 +`, + wantVersionCount: 1, + wantFirstVersion: "1.0.0", + wantFirstDate: "2025-01-15", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir := t.TempDir() + changelogPath := filepath.Join(tmpDir, "CHANGELOG.md") + + if err := os.WriteFile(changelogPath, []byte(tt.content), 0644); err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + changelog, err := Parse(changelogPath) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + + if len(changelog.Versions) != tt.wantVersionCount { + t.Errorf("Version count = %d, want %d", len(changelog.Versions), tt.wantVersionCount) + } + + if tt.wantVersionCount > 0 { + if changelog.Versions[0].Number != tt.wantFirstVersion { + t.Errorf("First version = %s, want %s", changelog.Versions[0].Number, tt.wantFirstVersion) + } + if changelog.Versions[0].Date != tt.wantFirstDate { + t.Errorf("First date = %s, want %s", changelog.Versions[0].Date, tt.wantFirstDate) + } + } + }) + } +} + +func TestParseNonExistent(t *testing.T) { + tmpDir := t.TempDir() + changelogPath := filepath.Join(tmpDir, "NONEXISTENT.md") + + changelog, err := Parse(changelogPath) + if err != nil { + t.Fatalf("Parse() should not error on non-existent file: %v", err) + } + + if len(changelog.Versions) != 0 { + t.Errorf("Empty changelog should have 0 versions, got %d", len(changelog.Versions)) + } + + if !strings.Contains(changelog.Header, "Keep a Changelog") { + t.Errorf("Default header should contain 'Keep a Changelog'") + } +} + +func TestBuild(t *testing.T) { + tests := []struct { + name string + entries []changeset.Entry + version string + date string + wantSectionCnt int + wantFirstType string + wantBreaking bool + }{ + { + name: "single entry", + entries: []changeset.Entry{ + {Type: "added", Summary: "New feature"}, + }, + version: "1.0.0", + date: "2025-01-15", + wantSectionCnt: 1, + wantFirstType: "added", + wantBreaking: false, + }, + { + name: "multiple types in correct order", + entries: []changeset.Entry{ + {Type: "fixed", Summary: "Bug fix"}, + {Type: "added", Summary: "New feature"}, + {Type: "changed", Summary: "Updated API"}, + }, + version: "2.0.0", + date: "2025-01-20", + wantSectionCnt: 3, + wantFirstType: "added", + }, + { + name: "entry with scope", + entries: []changeset.Entry{ + {Type: "added", Scope: "cli", Summary: "New command"}, + }, + version: "1.1.0", + date: "2025-01-18", + wantSectionCnt: 1, + wantFirstType: "added", + }, + { + name: "breaking change", + entries: []changeset.Entry{ + {Type: "changed", Summary: "API change", Breaking: true}, + }, + version: "2.0.0", + date: "2025-02-01", + wantSectionCnt: 1, + wantFirstType: "changed", + wantBreaking: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + version, err := Build(tt.entries, tt.version, tt.date) + if err != nil { + t.Fatalf("Build() error = %v", err) + } + + if version.Number != tt.version { + t.Errorf("Version number = %s, want %s", version.Number, tt.version) + } + + if version.Date != tt.date { + t.Errorf("Version date = %s, want %s", version.Date, tt.date) + } + + if len(version.Sections) != tt.wantSectionCnt { + t.Errorf("Section count = %d, want %d", len(version.Sections), tt.wantSectionCnt) + } + + if tt.wantSectionCnt > 0 { + if version.Sections[0].Type != tt.wantFirstType { + t.Errorf("First section type = %s, want %s", version.Sections[0].Type, tt.wantFirstType) + } + + if tt.wantBreaking { + firstEntry := version.Sections[0].Entries[0] + if !strings.Contains(firstEntry, "**BREAKING:**") { + t.Errorf("Breaking change should have **BREAKING:** prefix, got: %s", firstEntry) + } + } + } + }) + } +} + +func TestBuildInvalidVersion(t *testing.T) { + entries := []changeset.Entry{{Type: "added", Summary: "Test"}} + + invalidVersions := []string{ + "v1.0.0", + "1.0", + "1.0.0.0", + "abc", + } + + for _, version := range invalidVersions { + t.Run("invalid_version_"+version, func(t *testing.T) { + _, err := Build(entries, version, "2025-01-15") + if err == nil { + t.Errorf("Build() should error for invalid version %s", version) + } + }) + } +} + +func TestBuildInvalidDate(t *testing.T) { + entries := []changeset.Entry{{Type: "added", Summary: "Test"}} + + invalidDates := []string{ + "2025-13-01", + "2025-01-32", + "01-15-2025", + "2025/01/15", + "not-a-date", + } + + for _, date := range invalidDates { + t.Run("invalid_date_"+date, func(t *testing.T) { + _, err := Build(entries, "1.0.0", date) + if err == nil { + t.Errorf("Build() should error for invalid date %s", date) + } + }) + } +} + +func TestMerge(t *testing.T) { + tests := []struct { + name string + existingVersions []Version + newVersion Version + wantPositionIndex int + }{ + { + name: "merge into empty changelog", + existingVersions: []Version{}, + newVersion: Version{Number: "1.0.0", Date: "2025-01-15"}, + wantPositionIndex: 0, + }, + { + name: "merge below unreleased", + existingVersions: []Version{ + {Number: "Unreleased", Date: "Unreleased"}, + {Number: "1.0.0", Date: "2025-01-10"}, + }, + newVersion: Version{Number: "1.1.0", Date: "2025-01-15"}, + wantPositionIndex: 1, + }, + { + name: "merge at top when no unreleased", + existingVersions: []Version{ + {Number: "1.0.0", Date: "2025-01-10"}, + }, + newVersion: Version{Number: "1.1.0", Date: "2025-01-15"}, + wantPositionIndex: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + changelog := &Changelog{ + Versions: tt.existingVersions, + } + + Merge(changelog, &tt.newVersion) + + if changelog.Versions[tt.wantPositionIndex].Number != tt.newVersion.Number { + t.Errorf("Version at position %d = %s, want %s", + tt.wantPositionIndex, + changelog.Versions[tt.wantPositionIndex].Number, + tt.newVersion.Number) + } + }) + } +} + +func TestWrite(t *testing.T) { + tmpDir := t.TempDir() + changelogPath := filepath.Join(tmpDir, "CHANGELOG.md") + + changelog := &Changelog{ + Header: "# Changelog\n\nTest changelog", + Versions: []Version{ + { + Number: "1.0.0", + Date: "2025-01-15", + Sections: []Section{ + { + Type: "added", + Entries: []string{"New feature A", "New feature B"}, + }, + { + Type: "fixed", + Entries: []string{"Bug fix C"}, + }, + }, + }, + }, + } + + err := Write(changelogPath, changelog, tmpDir) + if err != nil { + t.Fatalf("Write() error = %v", err) + } + + if _, err := os.Stat(changelogPath); os.IsNotExist(err) { + t.Fatalf("CHANGELOG.md was not created") + } + + content, err := os.ReadFile(changelogPath) + if err != nil { + t.Fatalf("Failed to read CHANGELOG.md: %v", err) + } + + contentStr := string(content) + + if !strings.Contains(contentStr, "# Changelog") { + t.Errorf("Missing header") + } + if !strings.Contains(contentStr, "## [1.0.0] - 2025-01-15") { + t.Errorf("Missing version header") + } + if !strings.Contains(contentStr, "### Added") { + t.Errorf("Missing Added section") + } + if !strings.Contains(contentStr, "### Fixed") { + t.Errorf("Missing Fixed section") + } + if !strings.Contains(contentStr, "- New feature A") { + t.Errorf("Missing entry: New feature A") + } + if !strings.Contains(contentStr, "- Bug fix C") { + t.Errorf("Missing entry: Bug fix C") + } +} + +func TestValidateVersion(t *testing.T) { + tests := []struct { + version string + wantErr bool + }{ + {"1.0.0", false}, + {"0.1.0", false}, + {"10.20.30", false}, + {"v1.0.0", true}, + {"1.0", true}, + {"1.0.0.0", true}, + {"1.x.0", true}, + {"", true}, + } + + for _, tt := range tests { + t.Run(tt.version, func(t *testing.T) { + err := ValidateVersion(tt.version) + if (err != nil) != tt.wantErr { + t.Errorf("ValidateVersion(%s) error = %v, wantErr %v", tt.version, err, tt.wantErr) + } + }) + } +} + +func TestValidateDate(t *testing.T) { + tests := []struct { + date string + wantErr bool + }{ + {"2025-01-15", false}, + {"2024-12-31", false}, + {"2025-13-01", true}, + {"2025-01-32", true}, + {"01-15-2025", true}, + {"2025/01/15", true}, + {"not-a-date", true}, + {"", true}, + } + + for _, tt := range tests { + t.Run(tt.date, func(t *testing.T) { + err := ValidateDate(tt.date) + if (err != nil) != tt.wantErr { + t.Errorf("ValidateDate(%s) error = %v, wantErr %v", tt.date, err, tt.wantErr) + } + }) + } +} + +func TestParseGitHubURL(t *testing.T) { + tests := []struct { + name string + remoteURL string + want string + }{ + { + name: "https format", + remoteURL: "https://github.com/user/repo.git", + want: "https://github.com/user/repo", + }, + { + name: "https without .git", + remoteURL: "https://github.com/user/repo", + want: "https://github.com/user/repo", + }, + { + name: "ssh format", + remoteURL: "git@github.com:user/repo.git", + want: "https://github.com/user/repo", + }, + { + name: "ssh without .git", + remoteURL: "git@github.com:user/repo", + want: "https://github.com/user/repo", + }, + { + name: "non-github url", + remoteURL: "https://gitlab.com/user/repo.git", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseGitHubURL(tt.remoteURL) + if got != tt.want { + t.Errorf("parseGitHubURL(%s) = %s, want %s", tt.remoteURL, got, tt.want) + } + }) + } +} + +func TestSectionOrdering(t *testing.T) { + entries := []changeset.Entry{ + {Type: "security", Summary: "Security fix"}, + {Type: "removed", Summary: "Removed feature"}, + {Type: "fixed", Summary: "Bug fix"}, + {Type: "changed", Summary: "Changed behavior"}, + {Type: "added", Summary: "New feature"}, + } + + version, err := Build(entries, "1.0.0", "2025-01-15") + if err != nil { + t.Fatalf("Build() error = %v", err) + } + + expectedOrder := []string{"added", "changed", "removed", "fixed", "security"} + if len(version.Sections) != len(expectedOrder) { + t.Fatalf("Expected %d sections, got %d", len(expectedOrder), len(version.Sections)) + } + + for i, expectedType := range expectedOrder { + if version.Sections[i].Type != expectedType { + t.Errorf("Section %d: got type %s, want %s", i, version.Sections[i].Type, expectedType) + } + } +} + +func TestEntrySorting(t *testing.T) { + entries := []changeset.Entry{ + {Type: "added", Summary: "Zebra feature"}, + {Type: "added", Summary: "Apple feature"}, + {Type: "added", Summary: "Mango feature"}, + } + + version, err := Build(entries, "1.0.0", "2025-01-15") + if err != nil { + t.Fatalf("Build() error = %v", err) + } + + if len(version.Sections) != 1 { + t.Fatalf("Expected 1 section, got %d", len(version.Sections)) + } + + sortedEntries := version.Sections[0].Entries + if len(sortedEntries) != 3 { + t.Fatalf("Expected 3 entries, got %d", len(sortedEntries)) + } + + if !strings.Contains(sortedEntries[0], "Apple") { + t.Errorf("First entry should contain 'Apple', got: %s", sortedEntries[0]) + } + if !strings.Contains(sortedEntries[1], "Mango") { + t.Errorf("Second entry should contain 'Mango', got: %s", sortedEntries[1]) + } + if !strings.Contains(sortedEntries[2], "Zebra") { + t.Errorf("Third entry should contain 'Zebra', got: %s", sortedEntries[2]) + } +} + +func TestScopeFormatting(t *testing.T) { + entries := []changeset.Entry{ + {Type: "added", Scope: "cli", Summary: "New command"}, + } + + version, err := Build(entries, "1.0.0", "2025-01-15") + if err != nil { + t.Fatalf("Build() error = %v", err) + } + + entry := version.Sections[0].Entries[0] + if !strings.Contains(entry, "**cli:**") { + t.Errorf("Entry should contain formatted scope, got: %s", entry) + } +} diff --git a/internal/diff/diff.go b/internal/diff/diff.go index 42c5617..b2fa21f 100644 --- a/internal/diff/diff.go +++ b/internal/diff/diff.go @@ -34,6 +34,16 @@ type Edit struct { NewContent string // new content (only used for Replace operations) } +type outputEdit struct { + edit Edit + origPosition int +} + +type mergeInfo struct { + partnIndex int // index of the partner edit + isDelete bool +} + // Diff represents a generic diffing algorithm. type Diff interface { // Compute computes the edit operations needed to transform a into b. @@ -367,11 +377,6 @@ func MergeReplacements(edits []Edit) []Edit { return edits } - type mergeInfo struct { - partnIndex int // index of the partner edit - isDelete bool - } - merged := make(map[int]mergeInfo) const lookAheadWindow = 50 @@ -409,7 +414,7 @@ func MergeReplacements(edits []Edit) []Edit { } } - for i := 0; i < len(edits); i++ { + for i := range edits { if _, exists := merged[i]; exists || edits[i].Kind != Insert { continue } @@ -427,11 +432,6 @@ func MergeReplacements(edits []Edit) []Edit { } } - type outputEdit struct { - edit Edit - origPosition int - } - outputs := make([]outputEdit, 0, len(edits)) for i := range edits { @@ -480,15 +480,15 @@ func MergeReplacements(edits []Edit) []Edit { for _, out := range outputs { result = append(result, out.edit) } - return result } // areSimilarLines determines if two lines are similar enough to be considered a replacement. // // Uses a two-phase similarity check: -// 1. Common prefix must be at least 70% of the shorter line -// 2. Remaining suffixes must be at least 60% similar (Levenshtein-like check) +// +// 1. Common prefix must be at least 70% of the shorter line +// 2. Remaining suffixes must be at least 60% similar (Levenshtein-like check) func areSimilarLines(a, b string) bool { if a == b { return true @@ -501,7 +501,7 @@ func areSimilarLines(a, b string) bool { } commonPrefix := 0 - for i := 0; i < minLen; i++ { + for i := range minLen { if a[i] == b[i] { commonPrefix++ } else { @@ -530,10 +530,8 @@ func areSimilarLines(a, b string) bool { } maxSuffixLen := max(suffixLenB, suffixLenA) - if maxSuffixLen > 0 && float64(lenDiff)/float64(maxSuffixLen) > 0.3 { return false } - return true }