Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions cmd/changelog-extract-notes/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package main

import (
"flag"
"fmt"
"os"
"path/filepath"

"github.com/hashicorp/go-changelog/parser"
)

func main() {
wd, err := os.Getwd()
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}

var changelogPath string
flag.StringVar(&changelogPath, "path", filepath.Join(wd, "CHANGELOG.md"), "path to the changelog file")

// extractVersion represents version to extract changelog for (e.g. 1.0.0)
extractVersion := flag.Arg(0)
flag.Parse()

if extractVersion == "" {
fmt.Fprintf(os.Stderr, "Must specify version\n\n")
flag.Usage()
os.Exit(1)
}

f, err := os.Open(changelogPath)
if err != nil {
fmt.Fprintf(os.Stderr, "failed to open file: %s", err)
os.Exit(1)
}

sp, err := parser.NewSectionParser(f)
if err != nil {
fmt.Fprintf(os.Stderr, "unable to read changelog file: %s", err)
os.Exit(1)
}
s, err := sp.Section(extractVersion)
if err != nil {
fmt.Fprintln(os.Stderr, err.Error())
os.Exit(1)
}

_, err = os.Stdout.Write(s.Body)
if err != nil {
fmt.Fprintln(os.Stderr, err.Error())
os.Exit(1)
}
}
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ go 1.16
require (
github.com/go-git/go-billy/v5 v5.3.1
github.com/go-git/go-git/v5 v5.4.2
github.com/google/go-cmp v0.3.0
github.com/google/go-github v17.0.0+incompatible
github.com/google/go-querystring v1.0.0 // indirect
github.com/manifoldco/promptui v0.8.0
Expand Down
19 changes: 19 additions & 0 deletions parser/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package parser

import "fmt"

type VersionNotFoundErr struct {
Version string
}

func (e *VersionNotFoundErr) Is(target error) bool {
tErr, ok := target.(*VersionNotFoundErr)
if !ok {
return false
}
return tErr.Version == e.Version
}

func (e *VersionNotFoundErr) Error() string {
return fmt.Sprintf("version %s not found", e.Version)
}
122 changes: 122 additions & 0 deletions parser/parser.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package parser

import (
"bytes"
"fmt"
"io"
"regexp"
)

var (
defaultSectionReFmt = `(?s)(?P<header>## %s[^\n]*)
(?P<body>.+?)
(?:## .+|$)`
headerMatchName = "header"
bodyMatchName = "body"
)

type SectionParser struct {
RegexpFormat string
content []byte
}

func NewSectionParser(r io.Reader) (*SectionParser, error) {
b, err := io.ReadAll(r)
if err != nil {
return nil, err
}

return &SectionParser{
RegexpFormat: defaultSectionReFmt,
content: b,
}, nil
}

type SectionRange struct {
HeaderRange *ByteRange
BodyRange *ByteRange
}

type ByteRange struct {
From, To int
}

func (p *SectionParser) regexpFormat() string {
if p.RegexpFormat == "" {
return defaultSectionReFmt
}
return p.RegexpFormat
}

func (p *SectionParser) regexp(v string) (*regexp.Regexp, error) {
escapedVersion := regexp.QuoteMeta(v)
return regexp.Compile(fmt.Sprintf(p.regexpFormat(), escapedVersion))
}

func (p *SectionParser) SectionRange(v string) (*SectionRange, error) {
re, err := p.regexp(v)
if err != nil {
return nil, err
}

loc := re.FindSubmatchIndex(p.content)
if loc == nil {
return nil, &VersionNotFoundErr{v}
}

headerIdx, err := findSubexpIndexes(re, headerMatchName)
if err != nil {
return nil, err
}

bodyIdx, err := findSubexpIndexes(re, bodyMatchName)
if err != nil {
return nil, err
}

return &SectionRange{
HeaderRange: &ByteRange{
From: loc[headerIdx.from],
To: loc[headerIdx.to],
},
BodyRange: &ByteRange{
From: loc[bodyIdx.from],
To: loc[bodyIdx.to],
},
}, nil
}

type index struct {
from, to int
}

func findSubexpIndexes(re *regexp.Regexp, name string) (*index, error) {
for i, seName := range re.SubexpNames() {
if seName == name {
from := i * 2
return &index{from, from + 1}, nil
}
}

return nil, fmt.Errorf("subexpression %q not found", name)
}

type Section struct {
Header []byte
Body []byte
}

func (p *SectionParser) Section(v string) (*Section, error) {
sr, err := p.SectionRange(v)
if err != nil {
return nil, err
}

headerRng := sr.HeaderRange
bodyRng := sr.BodyRange

return &Section{
Header: bytes.TrimSpace(p.content[headerRng.From:headerRng.To]),
Body: bytes.TrimSpace(p.content[bodyRng.From:bodyRng.To]),
}, nil
}
101 changes: 101 additions & 0 deletions parser/parser_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package parser

import (
"errors"
"fmt"
"strings"
"testing"

"github.com/google/go-cmp/cmp"
)

func TestParser_Section(t *testing.T) {
testCases := []struct {
name string
content string
version string

expectedSection *Section
expectedErr error
}{
{
"empty log",
"",
"0.12.0",
nil,
&VersionNotFoundErr{"0.12.0"},
},
{
"version not found",
`## 0.11.0

something

## 0.10.0

testing
`,
"0.12.0",
nil,
&VersionNotFoundErr{"0.12.0"},
},
{
"matching unreleased version",
`## 0.12.0 (Unreleased)

something

## 0.11.0

testing
`,
"0.12.0",
&Section{
Header: []byte("## 0.12.0 (Unreleased)"),
Body: []byte("something"),
},
nil,
},
{
"matching released version - top",
`## 0.12.0
matching text
with newline

## 0.11.99

- something
- else
`,
"0.12.0",
&Section{
Header: []byte("## 0.12.0"),
Body: []byte(`matching text
with newline`),
},
nil,
},
}
for i, tc := range testCases {
t.Run(fmt.Sprintf("%d-%s", i, tc.name), func(t *testing.T) {
r := strings.NewReader(tc.content)
p, err := NewSectionParser(r)
if err != nil {
t.Fatal(err)
}
s, err := p.Section(tc.version)
if err == nil && tc.expectedErr != nil {
t.Fatalf("expected error: %s", tc.expectedErr.Error())
}

if !errors.Is(err, tc.expectedErr) {
diff := cmp.Diff(tc.expectedErr, err)
t.Fatalf("error doesn't match: %s", diff)
}

if diff := cmp.Diff(tc.expectedSection, s); diff != "" {
t.Fatalf("parsed section don't match: %s", diff)
}
})
}
}