pax_global_header00006660000000000000000000000064151722263150014516gustar00rootroot0000000000000052 comment=ec5c7eab3c6e8fa458c735aa048daad2e58fc61d golang-github-git-pkgs-changelog-0.1.2/000077500000000000000000000000001517222631500177155ustar00rootroot00000000000000golang-github-git-pkgs-changelog-0.1.2/.github/000077500000000000000000000000001517222631500212555ustar00rootroot00000000000000golang-github-git-pkgs-changelog-0.1.2/.github/workflows/000077500000000000000000000000001517222631500233125ustar00rootroot00000000000000golang-github-git-pkgs-changelog-0.1.2/.github/workflows/ci.yml000066400000000000000000000020761517222631500244350ustar00rootroot00000000000000name: CI on: push: branches: [main] pull_request: branches: [main] permissions: {} jobs: test: runs-on: ubuntu-latest strategy: matrix: go-version: ['1.25'] steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: persist-credentials: false - name: Set up Go uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6 with: go-version: ${{ matrix.go-version }} - name: Build run: go build -v ./... - name: Test run: go test -v -race ./... lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: persist-credentials: false - name: Set up Go uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6 with: go-version: '1.25' - name: golangci-lint uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9 with: version: latest golang-github-git-pkgs-changelog-0.1.2/.github/workflows/zizmor.yml000066400000000000000000000011211517222631500253620ustar00rootroot00000000000000name: Zizmor on: push: branches: - main paths: - '.github/workflows/**' pull_request: branches: - main paths: - '.github/workflows/**' workflow_dispatch: jobs: zizmor: runs-on: ubuntu-latest permissions: contents: read security-events: write steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: Run zizmor uses: zizmorcore/zizmor-action@135698455da5c3b3e55f73f4419e481ab68cdd95 # v0.4.1 golang-github-git-pkgs-changelog-0.1.2/.gitignore000066400000000000000000000000161517222631500217020ustar00rootroot00000000000000*.test *.prof golang-github-git-pkgs-changelog-0.1.2/LICENSE000066400000000000000000000020571517222631500207260ustar00rootroot00000000000000MIT License Copyright (c) 2026 Andrew Nesbitt Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. golang-github-git-pkgs-changelog-0.1.2/README.md000066400000000000000000000046641517222631500212060ustar00rootroot00000000000000# changelog A Go library for parsing changelog files into structured entries. Supports Keep a Changelog, markdown header, and setext/underline formats with automatic detection. Port of the Ruby [changelog-parser](https://github.com/git-pkgs/changelog-parser) gem. ## Installation ```bash go get github.com/git-pkgs/changelog ``` ## Usage ### Parse a string ```go p := changelog.Parse(content) for _, v := range p.Versions() { entry, _ := p.Entry(v) fmt.Printf("%s (%v): %s\n", v, entry.Date, entry.Content) } ``` ### Parse a file ```go p, err := changelog.ParseFile("CHANGELOG.md") ``` ### Find and parse a changelog in a directory ```go p, err := changelog.FindAndParse(".") ``` Searches for common changelog filenames (CHANGELOG.md, NEWS, CHANGES, HISTORY, etc.) and parses the first match. ### Specify format explicitly ```go p := changelog.ParseWithFormat(content, changelog.FormatKeepAChangelog) p := changelog.ParseWithFormat(content, changelog.FormatMarkdown) p := changelog.ParseWithFormat(content, changelog.FormatUnderline) ``` ### Custom regex pattern ```go pattern := regexp.MustCompile(`^Version ([\d.]+) released (\d{4}-\d{2}-\d{2})`) p := changelog.ParseWithPattern(content, pattern) ``` The first capture group is the version string. An optional second capture group is parsed as a date (YYYY-MM-DD). ### Get content between versions ```go content, ok := p.Between("1.0.0", "2.0.0") ``` ### Fetch and parse from a repository URL ```go p, err := changelog.FetchAndParse(ctx, "https://github.com/owner/repo", "CHANGELOG.md") ``` Constructs a raw content URL (GitHub and GitLab are supported), fetches the file, and parses it. You can also build the raw URL yourself: ```go url, err := changelog.RawContentURL("https://github.com/owner/repo", "CHANGELOG.md") // "https://raw.githubusercontent.com/owner/repo/HEAD/CHANGELOG.md" ``` ### Find line number for a version ```go line := p.LineForVersion("1.0.0") // 0-based, -1 if not found ``` ## Supported formats **Keep a Changelog** (`## [1.0.0] - 2024-01-15`): ```markdown ## [Unreleased] ## [1.1.0] - 2024-03-15 ### Added - New feature ## [1.0.0] - 2024-01-15 - Initial release ``` **Markdown headers** (`## 1.0.0 (2024-01-15)` or `### v1.0.0`): ```markdown ## 2.0.0 (2024-03-01) - Breaking changes ## 1.5.0 - New features ``` **Setext/underline** (version with `===` or `---` underline): ```markdown 3.0.0 ===== Major release. 2.1.0 ----- Minor release. ``` ## License MIT golang-github-git-pkgs-changelog-0.1.2/changelog.go000066400000000000000000000253261517222631500222030ustar00rootroot00000000000000// Package changelog parses changelog files into structured entries. // // It supports three common formats: Keep a Changelog (## [version] - date), // markdown headers (## version or ### version), and setext/underline style // (version\n=====). Format detection is automatic by default. // // Basic usage: // // p := changelog.Parse(content) // for _, v := range p.Versions() { // entry, _ := p.Entry(v) // fmt.Printf("%s: %s\n", v, entry.Content) // } // // Parse a file: // // p, err := changelog.ParseFile("CHANGELOG.md") // // Find and parse a changelog in a directory: // // p, err := changelog.FindAndParse(".") package changelog import ( "os" "path/filepath" "regexp" "slices" "strings" "time" ) // Format represents a changelog file format. type Format int const ( FormatAuto Format = iota // Auto-detect format FormatKeepAChangelog // ## [version] - date FormatMarkdown // ## version (date) FormatUnderline // version\n===== ) // Entry holds the parsed data for a single changelog version. type Entry struct { Date *time.Time Content string } // Compiled patterns for each format. var ( keepAChangelog = regexp.MustCompile(`(?m)^##\s+\[([^\]]+)\](?:\s+-\s+(\d{4}-\d{2}-\d{2}))?`) markdownHeader = regexp.MustCompile(`(?m)^#{1,3}\s+v?([\w.+-]+\.[\w.+-]+[a-zA-Z0-9])(?:\s+\((\d{4}-\d{2}-\d{2})\))?`) underlineHeader = regexp.MustCompile(`(?m)^([\w.+-]+\.[\w.+-]+[a-zA-Z0-9])\n[=-]+`) ) // Common changelog filenames in priority order. var changelogFilenames = []string{ "changelog", "news", "changes", "history", "release", "whatsnew", "releases", } // Allowed changelog file extensions. var changelogExtensions = []string{".md", ".txt", ".rst", ".rdoc", ".markdown", ""} type versionEntry struct { version string entry Entry } // Parser holds the parsed changelog data and provides access methods. type Parser struct { content string pattern *regexp.Regexp matchGroup int entries []versionEntry parsed bool } // Parse creates a parser with automatic format detection. func Parse(content string) *Parser { p := &Parser{ content: content, matchGroup: 1, } p.pattern = p.detectFormat() return p } // ParseWithFormat creates a parser using the specified format. func ParseWithFormat(content string, format Format) *Parser { p := &Parser{ content: content, matchGroup: 1, } switch format { case FormatKeepAChangelog: p.pattern = keepAChangelog case FormatMarkdown: p.pattern = markdownHeader case FormatUnderline: p.pattern = underlineHeader default: p.pattern = p.detectFormat() } return p } // ParseWithPattern creates a parser using a custom regex pattern. // The pattern must have at least one capture group for the version string. // An optional second capture group captures the date (YYYY-MM-DD). // The (?m) flag is automatically added if not already present, so that // ^ and $ match line boundaries. func ParseWithPattern(content string, pattern *regexp.Regexp) *Parser { expr := pattern.String() if !strings.Contains(expr, "(?m)") { pattern = regexp.MustCompile("(?m)" + expr) } return &Parser{ content: content, pattern: pattern, matchGroup: 1, } } // ParseFile reads and parses a changelog file. func ParseFile(path string) (*Parser, error) { data, err := os.ReadFile(path) if err != nil { return nil, err } return Parse(string(data)), nil } // FindChangelog locates a changelog file in the given directory. // Returns the path to the changelog file, or empty string if not found. func FindChangelog(directory string) (string, error) { dirEntries, err := os.ReadDir(directory) if err != nil { return "", err } var files []string for _, e := range dirEntries { if !e.IsDir() { files = append(files, e.Name()) } } for _, name := range changelogFilenames { var candidates []string for _, f := range files { if strings.HasSuffix(strings.ToLower(f), ".sh") { continue } lower := strings.ToLower(f) base := lower ext := filepath.Ext(lower) if ext != "" { base = lower[:len(lower)-len(ext)] } if base != name { continue } if slices.Contains(changelogExtensions, ext) { candidates = append(candidates, f) } } if len(candidates) == 1 { return filepath.Join(directory, candidates[0]), nil } for _, candidate := range candidates { path := filepath.Join(directory, candidate) info, err := os.Stat(path) if err != nil { continue } size := info.Size() if size > 1_000_000 || size < 100 { continue } return path, nil } } return "", nil } // FindAndParse locates a changelog file in the directory and parses it. func FindAndParse(directory string) (*Parser, error) { path, err := FindChangelog(directory) if err != nil { return nil, err } if path == "" { return nil, nil } return ParseFile(path) } // Versions returns the version strings in the order they appear in the changelog. func (p *Parser) Versions() []string { p.ensureParsed() versions := make([]string, len(p.entries)) for i, ve := range p.entries { versions[i] = ve.version } return versions } // Entry returns the entry for a specific version. func (p *Parser) Entry(version string) (Entry, bool) { p.ensureParsed() for _, ve := range p.entries { if ve.version == version { return ve.entry, true } } return Entry{}, false } // Entries returns all entries as a map. Note that Go maps do not preserve // insertion order; use Versions() + Entry() if order matters. func (p *Parser) Entries() map[string]Entry { p.ensureParsed() m := make(map[string]Entry, len(p.entries)) for _, ve := range p.entries { m[ve.version] = ve.entry } return m } // Between returns the content between two version headers. // Either version can be empty to indicate the start or end of the changelog. // Returns the content and true if found, or empty string and false if not. func (p *Parser) Between(oldVersion, newVersion string) (string, bool) { oldLine := p.LineForVersion(oldVersion) newLine := p.LineForVersion(newVersion) lines := strings.Split(p.content, "\n") var start, end int found := false switch { case oldLine >= 0 && newLine >= 0: if oldLine < newLine { // Ascending: old appears first, take from old line to end start = oldLine end = len(lines) } else { // Descending (typical): new appears first, take from new to old start = newLine end = oldLine } found = true case oldLine >= 0: if oldLine == 0 { return "", false } start = 0 end = oldLine found = true case newLine >= 0: start = newLine end = len(lines) found = true } if !found { return "", false } result := strings.Join(lines[start:end], "\n") result = strings.TrimRight(result, " \t\n") return result, true } // LineForVersion returns the 0-based line number where the given version // header appears, or -1 if not found. Strips a leading "v" prefix for matching. func (p *Parser) LineForVersion(version string) int { if version == "" { return -1 } version = strings.TrimPrefix(version, "v") version = strings.TrimPrefix(version, "V") escaped := regexp.QuoteMeta(version) // Go's regexp doesn't support lookbehinds, so we check surrounding // characters manually after finding a match. versionRe := regexp.MustCompile(escaped) rangeRe := regexp.MustCompile(escaped + `\.\.`) lines := strings.Split(p.content, "\n") for i, line := range lines { if !containsVersion(line, versionRe) { continue } if rangeRe.MatchString(line) { continue } // Check if this line looks like a version header if strings.HasPrefix(line, "#") || strings.HasPrefix(line, "!") || strings.HasPrefix(line, "==") { return i } versionLineRe := regexp.MustCompile(`^v?` + escaped + `:?\s`) if versionLineRe.MatchString(line) { return i } bracketRe := regexp.MustCompile(`^\[` + escaped + `\]`) if bracketRe.MatchString(line) { return i } bulletRe := regexp.MustCompile(`(?i)^[+*\-]\s+(version\s+)?` + escaped) if bulletRe.MatchString(line) { return i } dateLineRe := regexp.MustCompile(`^\d{4}-\d{2}-\d{2}`) if dateLineRe.MatchString(line) { return i } // Check if next line is an underline if i+1 < len(lines) { underlineRe := regexp.MustCompile(`^[=\-+]{3,}\s*$`) if underlineRe.MatchString(lines[i+1]) { return i } } } return -1 } // containsVersion checks if a line contains the version string without it // being a substring of a longer version (e.g. 1.0.1 should not match inside 1.0.10). // Allows a preceding 'v' or 'V' since version headers commonly use that prefix. func containsVersion(line string, versionRe *regexp.Regexp) bool { for _, loc := range versionRe.FindAllStringIndex(line, -1) { // Check char before match: must not be a dot or word char (except v/V prefix) if loc[0] > 0 { prev := line[loc[0]-1] if prev == '.' { continue } if isWordChar(prev) && prev != 'v' && prev != 'V' { continue } } // Check char after match: must not be dot, dash, or word char if loc[1] < len(line) { next := line[loc[1]] if next == '.' || next == '-' || isWordChar(next) { continue } } return true } return false } func isWordChar(b byte) bool { return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') || b == '_' } func (p *Parser) detectFormat() *regexp.Regexp { if keepAChangelog.MatchString(p.content) { return keepAChangelog } if underlineHeader.MatchString(p.content) { return underlineHeader } return markdownHeader } func (p *Parser) ensureParsed() { if p.parsed { return } p.parsed = true p.doParse() } func (p *Parser) doParse() { if p.content == "" { return } matches := p.pattern.FindAllStringSubmatchIndex(p.content, -1) if matches == nil { return } for i, match := range matches { version := p.extractGroup(match, p.matchGroup) date := p.extractDate(match) headerEnd := match[1] // end of entire match var contentEnd int if i+1 < len(matches) { contentEnd = matches[i+1][0] // start of next match } else { contentEnd = len(p.content) } content := strings.TrimSpace(p.content[headerEnd:contentEnd]) var datep *time.Time if date != nil { datep = date } p.entries = append(p.entries, versionEntry{ version: version, entry: Entry{ Date: datep, Content: content, }, }) } } func (p *Parser) extractGroup(match []int, group int) string { start := match[group*2] end := match[group*2+1] if start < 0 { return "" } return p.content[start:end] } func (p *Parser) extractDate(match []int) *time.Time { group := p.matchGroup + 1 if group*2+1 >= len(match) { return nil } start := match[group*2] end := match[group*2+1] if start < 0 { return nil } dateStr := p.content[start:end] t, err := time.Parse("2006-01-02", dateStr) if err != nil { return nil } return &t } golang-github-git-pkgs-changelog-0.1.2/changelog_test.go000066400000000000000000000375361517222631500232500ustar00rootroot00000000000000package changelog import ( "os" "path/filepath" "regexp" "strings" "testing" "time" ) func mustReadFixture(t *testing.T, name string) string { t.Helper() data, err := os.ReadFile(filepath.Join("testdata", name)) if err != nil { t.Fatal(err) } return string(data) } func TestParseEmpty(t *testing.T) { p := Parse("") if len(p.Versions()) != 0 { t.Errorf("expected no versions, got %d", len(p.Versions())) } if len(p.Entries()) != 0 { t.Errorf("expected no entries, got %d", len(p.Entries())) } } func TestKeepAChangelogFormat(t *testing.T) { content := mustReadFixture(t, "keep_a_changelog.md") p := Parse(content) t.Run("detects format", func(t *testing.T) { if p.pattern != keepAChangelog { t.Error("expected keep-a-changelog pattern") } }) t.Run("parses all versions", func(t *testing.T) { versions := p.Versions() if len(versions) != 4 { t.Fatalf("expected 4 versions, got %d", len(versions)) } want := []string{"Unreleased", "1.1.0", "1.0.1", "1.0.0"} for i, v := range want { if versions[i] != v { t.Errorf("version[%d] = %q, want %q", i, versions[i], v) } } }) t.Run("extracts dates", func(t *testing.T) { entry, ok := p.Entry("Unreleased") if !ok { t.Fatal("Unreleased not found") } if entry.Date != nil { t.Error("expected nil date for Unreleased") } entry, _ = p.Entry("1.1.0") assertDate(t, entry.Date, time.March, 15) entry, _ = p.Entry("1.0.1") assertDate(t, entry.Date, time.February, 1) entry, _ = p.Entry("1.0.0") assertDate(t, entry.Date, time.January, 15) }) t.Run("extracts content", func(t *testing.T) { entry, _ := p.Entry("1.1.0") if !strings.Contains(entry.Content, "User authentication system") { t.Error("expected 1.1.0 content to contain 'User authentication system'") } if !strings.Contains(entry.Content, "Memory leak in connection pool") { t.Error("expected 1.1.0 content to contain 'Memory leak in connection pool'") } entry, _ = p.Entry("1.0.0") if !strings.Contains(entry.Content, "Initial release") { t.Error("expected 1.0.0 content to contain 'Initial release'") } }) } func TestMarkdownHeaderFormat(t *testing.T) { content := mustReadFixture(t, "markdown_header.md") p := ParseWithFormat(content, FormatMarkdown) t.Run("parses versions with dates", func(t *testing.T) { entry, ok := p.Entry("2.0.0") if !ok { t.Fatal("2.0.0 not found") } assertDate(t, entry.Date, time.March, 1) }) t.Run("parses versions without dates", func(t *testing.T) { entry, ok := p.Entry("1.5.0") if !ok { t.Fatal("1.5.0 not found") } if entry.Date != nil { t.Error("expected nil date for 1.5.0") } }) t.Run("parses h3 headers", func(t *testing.T) { _, ok := p.Entry("1.4.2") if !ok { t.Error("1.4.2 not found") } }) t.Run("extracts content", func(t *testing.T) { entry, _ := p.Entry("2.0.0") if !strings.Contains(entry.Content, "Breaking changes") { t.Error("expected 2.0.0 content to contain 'Breaking changes'") } entry, _ = p.Entry("1.5.0") if !strings.Contains(entry.Content, "caching layer") { t.Error("expected 1.5.0 content to contain 'caching layer'") } }) } func TestUnderlineFormat(t *testing.T) { content := mustReadFixture(t, "underline.md") p := ParseWithFormat(content, FormatUnderline) t.Run("parses equals underline", func(t *testing.T) { _, ok := p.Entry("3.0.0") if !ok { t.Error("3.0.0 not found") } _, ok = p.Entry("2.0.0") if !ok { t.Error("2.0.0 not found") } }) t.Run("parses dash underline", func(t *testing.T) { _, ok := p.Entry("2.1.0") if !ok { t.Error("2.1.0 not found") } }) t.Run("extracts content", func(t *testing.T) { entry, _ := p.Entry("3.0.0") if !strings.Contains(entry.Content, "Complete rewrite") { t.Error("expected 3.0.0 content to contain 'Complete rewrite'") } entry, _ = p.Entry("2.1.0") if !strings.Contains(entry.Content, "Bug fixes") { t.Error("expected 2.1.0 content to contain 'Bug fixes'") } }) } func TestCustomPattern(t *testing.T) { t.Run("custom regex", func(t *testing.T) { content := "Version 1.2.0 released 2024-01-01\n- Feature A\n\nVersion 1.1.0 released 2023-12-01\n- Feature B\n" pattern := regexp.MustCompile(`^Version ([\d.]+) released (\d{4}-\d{2}-\d{2})`) p := ParseWithPattern(content, pattern) versions := p.Versions() if len(versions) != 2 { t.Fatalf("expected 2 versions, got %d", len(versions)) } if versions[0] != "1.2.0" { t.Errorf("expected first version 1.2.0, got %s", versions[0]) } if versions[1] != "1.1.0" { t.Errorf("expected second version 1.1.0, got %s", versions[1]) } entry, _ := p.Entry("1.2.0") assertDate(t, entry.Date, time.January, 1) }) t.Run("custom match group", func(t *testing.T) { content := "## Release v1.0.0\n\nContent here\n\n## Release v0.9.0\n\nMore content\n" pattern := regexp.MustCompile(`^## Release v([\d.]+)`) p := ParseWithPattern(content, pattern) versions := p.Versions() if len(versions) != 2 { t.Fatalf("expected 2 versions, got %d", len(versions)) } if versions[0] != "1.0.0" { t.Errorf("expected 1.0.0, got %s", versions[0]) } if versions[1] != "0.9.0" { t.Errorf("expected 0.9.0, got %s", versions[1]) } }) } func TestFormatDetection(t *testing.T) { t.Run("detects keep a changelog", func(t *testing.T) { p := Parse("## [1.0.0] - 2024-01-01\n\nContent") if p.pattern != keepAChangelog { t.Error("expected keep-a-changelog pattern") } }) t.Run("detects underline", func(t *testing.T) { p := Parse("1.0.0\n=====\n\nContent") if p.pattern != underlineHeader { t.Error("expected underline pattern") } }) t.Run("falls back to markdown", func(t *testing.T) { p := Parse("## 1.0.0\n\nContent") if p.pattern != markdownHeader { t.Error("expected markdown pattern") } }) } func TestLineForVersion(t *testing.T) { tests := []struct { name string content string version string wantLine int }{ { name: "keep a changelog header", content: "## [1.0.0] - 2024-01-01\n\nContent", version: "1.0.0", wantLine: 0, }, { name: "v prefix in version arg", content: "## v1.0.0\n\nContent", version: "v1.0.0", wantLine: 0, }, { name: "strips v prefix for matching", content: "## v1.0.0\n\nContent", version: "1.0.0", wantLine: 0, }, { name: "underlined version", content: "1.0.0\n=====\n\nContent", version: "1.0.0", wantLine: 0, }, { name: "bullet point version", content: "- version 1.0.0\n\nContent", version: "1.0.0", wantLine: 0, }, { name: "colon version", content: "1.0.0: Initial release\n\nContent", version: "1.0.0", wantLine: 0, }, { name: "not found", content: "## [1.0.0] - 2024-01-01\n\nContent", version: "2.0.0", wantLine: -1, }, { name: "empty version", content: "## [1.0.0] - 2024-01-01\n\nContent", version: "", wantLine: -1, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { p := Parse(tt.content) got := p.LineForVersion(tt.version) if got != tt.wantLine { t.Errorf("LineForVersion(%q) = %d, want %d", tt.version, got, tt.wantLine) } }) } } func TestLineForVersionSubstring(t *testing.T) { content := "## [1.0.10] - 2024-02-01\n\nContent for 1.0.10\n\n## [1.0.1] - 2024-01-01\n\nContent for 1.0.1\n" p := Parse(content) if got := p.LineForVersion("1.0.10"); got != 0 { t.Errorf("LineForVersion(1.0.10) = %d, want 0", got) } if got := p.LineForVersion("1.0.1"); got != 4 { t.Errorf("LineForVersion(1.0.1) = %d, want 4", got) } } func TestLineForVersionRange(t *testing.T) { content := "Supports versions 1.0.0..2.0.0\n\n## [1.0.0]\n\nContent" p := Parse(content) if got := p.LineForVersion("1.0.0"); got != 2 { t.Errorf("LineForVersion(1.0.0) = %d, want 2", got) } } func TestBetween(t *testing.T) { content := "## [3.0.0] - 2024-03-01\n\nVersion 3 content\n\n## [2.0.0] - 2024-02-01\n\nVersion 2 content\n\n## [1.0.0] - 2024-01-01\n\nVersion 1 content\n" p := Parse(content) t.Run("between two versions descending", func(t *testing.T) { result, ok := p.Between("1.0.0", "3.0.0") if !ok { t.Fatal("expected result") } if !strings.Contains(result, "Version 3 content") { t.Error("expected result to contain 'Version 3 content'") } if !strings.Contains(result, "Version 2 content") { t.Error("expected result to contain 'Version 2 content'") } if strings.Contains(result, "Version 1 content") { t.Error("expected result to NOT contain 'Version 1 content'") } }) t.Run("from new version to end", func(t *testing.T) { result, ok := p.Between("", "2.0.0") if !ok { t.Fatal("expected result") } if !strings.Contains(result, "Version 2 content") { t.Error("expected result to contain 'Version 2 content'") } if !strings.Contains(result, "Version 1 content") { t.Error("expected result to contain 'Version 1 content'") } }) t.Run("from start to old version", func(t *testing.T) { result, ok := p.Between("2.0.0", "") if !ok { t.Fatal("expected result") } if !strings.Contains(result, "Version 3 content") { t.Error("expected result to contain 'Version 3 content'") } if strings.Contains(result, "Version 2 content") { t.Error("expected result to NOT contain 'Version 2 content'") } }) t.Run("neither found", func(t *testing.T) { _, ok := p.Between("9.0.0", "8.0.0") if ok { t.Error("expected no result when versions not found") } }) t.Run("ascending changelog", func(t *testing.T) { ascending := "## [1.0.0] - 2024-01-01\n\nFirst\n\n## [2.0.0] - 2024-02-01\n\nSecond\n" ap := Parse(ascending) result, ok := ap.Between("1.0.0", "2.0.0") if !ok { t.Fatal("expected result") } if !strings.Contains(result, "Second") { t.Error("expected result to contain 'Second'") } }) } func TestParseFile(t *testing.T) { p, err := ParseFile(filepath.Join("testdata", "keep_a_changelog.md")) if err != nil { t.Fatal(err) } versions := p.Versions() if len(versions) != 4 { t.Fatalf("expected 4 versions, got %d", len(versions)) } if versions[3] != "1.0.0" { t.Errorf("expected last version 1.0.0, got %s", versions[3]) } } func TestFindChangelog(t *testing.T) { t.Run("empty directory", func(t *testing.T) { dir := t.TempDir() path, err := FindChangelog(dir) if err != nil { t.Fatal(err) } if path != "" { t.Errorf("expected empty path, got %q", path) } }) t.Run("finds changelog.md", func(t *testing.T) { dir := t.TempDir() content := "## [1.0.0] - 2024-01-01\n\nSome content that is long enough to pass the size check, we need at least one hundred bytes here to make sure.\n" if err := os.WriteFile(filepath.Join(dir, "CHANGELOG.md"), []byte(content), 0644); err != nil { t.Fatal(err) } path, err := FindChangelog(dir) if err != nil { t.Fatal(err) } if filepath.Base(path) != "CHANGELOG.md" { t.Errorf("expected CHANGELOG.md, got %s", path) } }) } func TestEdgeCases(t *testing.T) { t.Run("prerelease version", func(t *testing.T) { p := Parse("## [1.0.0-beta.1] - 2024-01-01\n\nBeta content") _, ok := p.Entry("1.0.0-beta.1") if !ok { t.Error("1.0.0-beta.1 not found") } }) t.Run("build metadata", func(t *testing.T) { p := Parse("## [1.0.0+build.123] - 2024-01-01\n\nBuild content") _, ok := p.Entry("1.0.0+build.123") if !ok { t.Error("1.0.0+build.123 not found") } }) t.Run("complex prerelease", func(t *testing.T) { p := Parse("## [2.0.0-x.7.z.92] - 2024-01-01\n\nComplex prerelease") _, ok := p.Entry("2.0.0-x.7.z.92") if !ok { t.Error("2.0.0-x.7.z.92 not found") } }) t.Run("empty version content", func(t *testing.T) { p := Parse("## [2.0.0] - 2024-02-01\n\n## [1.0.0] - 2024-01-01\n\nSome content\n") entry, ok := p.Entry("2.0.0") if !ok { t.Fatal("2.0.0 not found") } if entry.Content != "" { t.Errorf("expected empty content for 2.0.0, got %q", entry.Content) } entry, _ = p.Entry("1.0.0") if !strings.Contains(entry.Content, "Some content") { t.Error("expected 1.0.0 to contain 'Some content'") } }) t.Run("preserves version order", func(t *testing.T) { p := Parse("## [3.0.0] - 2024-03-01\n## [1.0.0] - 2024-01-01\n## [2.0.0] - 2024-02-01\n") versions := p.Versions() want := []string{"3.0.0", "1.0.0", "2.0.0"} if len(versions) != len(want) { t.Fatalf("expected %d versions, got %d", len(want), len(versions)) } for i, v := range want { if versions[i] != v { t.Errorf("version[%d] = %q, want %q", i, versions[i], v) } } }) } func TestEdgeCasesContent(t *testing.T) { t.Run("preserves markdown links", func(t *testing.T) { p := Parse("## [1.0.0] - 2024-01-01\n\n- Added [feature](https://example.com)\n- See [docs](https://docs.example.com) for details\n") entry, _ := p.Entry("1.0.0") if !strings.Contains(entry.Content, "[feature](https://example.com)") { t.Error("expected content to contain markdown link") } }) t.Run("preserves inline code", func(t *testing.T) { p := Parse("## [1.0.0] - 2024-01-01\n\n- Fixed `bug_in_function` method\n") entry, _ := p.Entry("1.0.0") if !strings.Contains(entry.Content, "`bug_in_function`") { t.Error("expected content to contain inline code") } }) t.Run("ignores link references", func(t *testing.T) { p := Parse("## [1.0.0] - 2024-01-01\n\nContent here\n\n[1.0.0]: https://github.com/example/repo/releases/tag/v1.0.0\n") versions := p.Versions() if len(versions) != 1 { t.Errorf("expected 1 version, got %d: %v", len(versions), versions) } entry, _ := p.Entry("1.0.0") if !strings.Contains(entry.Content, "[1.0.0]: https://github.com") { t.Error("expected content to contain link reference") } }) t.Run("mixed list markers", func(t *testing.T) { p := Parse("## [1.0.0] - 2024-01-01\n\n- Dash item\n* Asterisk item\n- Another dash\n") entry, _ := p.Entry("1.0.0") if !strings.Contains(entry.Content, "- Dash item") { t.Error("expected content to contain dash item") } if !strings.Contains(entry.Content, "* Asterisk item") { t.Error("expected content to contain asterisk item") } }) t.Run("nested lists", func(t *testing.T) { p := Parse("## [1.0.0] - 2024-01-01\n\n- Main item\n - Sub item one\n - Sub item two\n") entry, _ := p.Entry("1.0.0") if !strings.Contains(entry.Content, "- Sub item one") { t.Error("expected content to contain sub item") } }) t.Run("v prefix stripped", func(t *testing.T) { p := ParseWithFormat("## v1.0.0\n\nContent", FormatMarkdown) _, ok := p.Entry("1.0.0") if !ok { t.Error("1.0.0 not found (v prefix should be stripped)") } }) t.Run("unreleased section", func(t *testing.T) { p := Parse("## [Unreleased]\n\n- Work in progress\n\n## [1.0.0] - 2024-01-01\n\n- Released feature\n") entry, ok := p.Entry("Unreleased") if !ok { t.Fatal("Unreleased not found") } if entry.Date != nil { t.Error("expected nil date for Unreleased") } if !strings.Contains(entry.Content, "Work in progress") { t.Error("expected Unreleased content to contain 'Work in progress'") } }) t.Run("version with label", func(t *testing.T) { p := Parse("## [1.0.0] - 2024-01-01 - Codename Phoenix\n\nContent") entry, ok := p.Entry("1.0.0") if !ok { t.Fatal("1.0.0 not found") } assertDate(t, entry.Date, time.January, 1) }) } func TestComprehensiveFixture(t *testing.T) { content := mustReadFixture(t, "comprehensive.md") p := Parse(content) versions := p.Versions() if len(versions) != 8 { t.Fatalf("expected 8 versions, got %d: %v", len(versions), versions) } wantVersions := []string{ "Unreleased", "2.0.0-x.7.z.92", "1.5.0-beta.2", "1.4.0-rc.1", "1.3.0", "1.2.0", "1.1.0", "1.0.0", } for _, v := range wantVersions { if _, ok := p.Entry(v); !ok { t.Errorf("version %q not found", v) } } } func assertDate(t *testing.T, got *time.Time, month time.Month, day int) { t.Helper() if got == nil { t.Fatal("expected non-nil date") } const year = 2024 if got.Year() != year || got.Month() != month || got.Day() != day { t.Errorf("date = %v, want %d-%02d-%02d", got, year, month, day) } } golang-github-git-pkgs-changelog-0.1.2/fetch.go000066400000000000000000000041151517222631500213360ustar00rootroot00000000000000package changelog import ( "context" "fmt" "io" "net/http" "net/url" "strings" ) // RawContentURL constructs a URL that serves the raw content of a file in a // repository. Supports GitHub and GitLab. The repoURL should be the repository's // web URL (e.g. "https://github.com/owner/repo"). Trailing ".git" suffixes and // slashes are stripped automatically. func RawContentURL(repoURL, filename string) (string, error) { repoURL = strings.TrimSuffix(repoURL, ".git") repoURL = strings.TrimSuffix(repoURL, "/") parsed, err := url.Parse(repoURL) if err != nil { return "", fmt.Errorf("parsing repository URL: %w", err) } const maxURLParts = 3 const minURLParts = 2 parts := strings.SplitN(strings.TrimPrefix(parsed.Path, "/"), "/", maxURLParts) if len(parts) < minURLParts { return "", fmt.Errorf("cannot parse owner/repo from %s", repoURL) } owner := parts[0] repo := parts[1] switch parsed.Host { case "github.com": return fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/HEAD/%s", owner, repo, filename), nil case "gitlab.com": return fmt.Sprintf("https://gitlab.com/%s/%s/-/raw/HEAD/%s", owner, repo, filename), nil default: return "", fmt.Errorf("unsupported host %s (only github.com and gitlab.com are supported)", parsed.Host) } } // FetchAndParse fetches a changelog from a repository and parses it. // It constructs the raw content URL from the repository URL and changelog // filename, fetches the content over HTTP, and returns a Parser. func FetchAndParse(ctx context.Context, repoURL, filename string) (*Parser, error) { rawURL, err := RawContentURL(repoURL, filename) if err != nil { return nil, err } req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) if err != nil { return nil, err } resp, err := http.DefaultClient.Do(req) if err != nil { return nil, err } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("HTTP %d fetching %s", resp.StatusCode, rawURL) } body, err := io.ReadAll(resp.Body) if err != nil { return nil, err } return Parse(string(body)), nil } golang-github-git-pkgs-changelog-0.1.2/fetch_test.go000066400000000000000000000053361517222631500224030ustar00rootroot00000000000000package changelog import ( "context" "net/http" "net/http/httptest" "testing" ) func TestRawContentURL(t *testing.T) { tests := []struct { name string repoURL string filename string want string wantErr bool }{ { name: "github https", repoURL: "https://github.com/olivierlacan/keep-a-changelog", filename: "CHANGELOG.md", want: "https://raw.githubusercontent.com/olivierlacan/keep-a-changelog/HEAD/CHANGELOG.md", }, { name: "github with trailing .git", repoURL: "https://github.com/lodash/lodash.git", filename: "CHANGELOG.md", want: "https://raw.githubusercontent.com/lodash/lodash/HEAD/CHANGELOG.md", }, { name: "github with trailing slash", repoURL: "https://github.com/lodash/lodash/", filename: "CHANGELOG.md", want: "https://raw.githubusercontent.com/lodash/lodash/HEAD/CHANGELOG.md", }, { name: "gitlab https", repoURL: "https://gitlab.com/inkscape/inkscape", filename: "NEWS.md", want: "https://gitlab.com/inkscape/inkscape/-/raw/HEAD/NEWS.md", }, { name: "gitlab with trailing .git", repoURL: "https://gitlab.com/inkscape/inkscape.git", filename: "NEWS.md", want: "https://gitlab.com/inkscape/inkscape/-/raw/HEAD/NEWS.md", }, { name: "unsupported host", repoURL: "https://bitbucket.org/owner/repo", filename: "CHANGELOG.md", wantErr: true, }, { name: "no path segments", repoURL: "https://github.com/", filename: "CHANGELOG.md", wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := RawContentURL(tt.repoURL, tt.filename) if tt.wantErr { if err == nil { t.Fatalf("expected error, got nil") } return } if err != nil { t.Fatalf("unexpected error: %v", err) } if got != tt.want { t.Errorf("got %q, want %q", got, tt.want) } }) } } func TestFetchAndParse(t *testing.T) { changelogContent := "## [2.0.0] - 2024-03-01\n\nNew features\n\n## [1.0.0] - 2024-01-01\n\nInitial release\n" srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(changelogContent)) })) defer srv.Close() // We can't easily test with real GitHub/GitLab URLs, but we can test // the parsing side by testing FetchAndParse's error handling and // RawContentURL separately. For a real integration-like test, we'd // need to mock the URL construction. Instead, test that unsupported // hosts produce errors. t.Run("unsupported host returns error", func(t *testing.T) { _, err := FetchAndParse(context.Background(), "https://bitbucket.org/owner/repo", "CHANGELOG.md") if err == nil { t.Error("expected error for unsupported host") } }) } golang-github-git-pkgs-changelog-0.1.2/go.mod000066400000000000000000000000601517222631500210170ustar00rootroot00000000000000module github.com/git-pkgs/changelog go 1.25.6 golang-github-git-pkgs-changelog-0.1.2/testdata/000077500000000000000000000000001517222631500215265ustar00rootroot00000000000000golang-github-git-pkgs-changelog-0.1.2/testdata/comprehensive.md000066400000000000000000000017341517222631500247240ustar00rootroot00000000000000# Changelog All notable changes to this project will be documented in this file. ## [Unreleased] ### Added - Work in progress feature ## [2.0.0-x.7.z.92] - 2024-06-01 ### Changed - Complex prerelease version format ## [1.5.0-beta.2] - 2024-05-15 ### Added - Beta feature with [markdown link](https://example.com) - Another feature ### Fixed - Bug fix with `inline code` ## [1.4.0-rc.1] - 2024-04-01 Release candidate. ## [1.3.0] - 2024-03-15 ### Added - Feature using both list markers: * Sub-item with asterisk * Another sub-item - Main item with dash ### Deprecated - Old API endpoint ## [1.2.0] - 2024-02-01 ### Security - Fixed XSS vulnerability ## [1.1.0] - 2024-01-15 ### Fixed - Multiple fixes: - Fix one - Fix two ## [1.0.0] - 2024-01-01 Initial release. ### Added - Core functionality - Documentation [Unreleased]: https://github.com/example/project/compare/v1.5.0...HEAD [1.5.0]: https://github.com/example/project/compare/v1.4.0...v1.5.0 golang-github-git-pkgs-changelog-0.1.2/testdata/keep_a_changelog.md000066400000000000000000000011341517222631500253020ustar00rootroot00000000000000# 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.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] ### Added - New feature in development ## [1.1.0] - 2024-03-15 ### Added - User authentication system - OAuth2 support ### Fixed - Memory leak in connection pool ## [1.0.1] - 2024-02-01 ### Fixed - Critical bug in payment processing ## [1.0.0] - 2024-01-15 ### Added - Initial release - Core functionality - Documentation golang-github-git-pkgs-changelog-0.1.2/testdata/markdown_header.md000066400000000000000000000004101517222631500251750ustar00rootroot00000000000000# Project Changelog ## 2.0.0 (2024-03-01) Breaking changes release. - Removed deprecated methods - New API design ## 1.5.0 Feature release. - Added caching layer - Performance improvements ### 1.4.2 (2024-01-10) Patch release. - Fixed edge case in parser golang-github-git-pkgs-changelog-0.1.2/testdata/underline.md000066400000000000000000000003261517222631500240360ustar00rootroot00000000000000Changelog ========= 3.0.0 ===== Major release with breaking changes. - Complete rewrite - New architecture 2.1.0 ----- Minor release. - Bug fixes - Documentation updates 2.0.0 ===== Initial stable release.