diff --git a/cmd/changelog-extract-notes/main.go b/cmd/changelog-extract-notes/main.go new file mode 100644 index 0000000..273b47d --- /dev/null +++ b/cmd/changelog-extract-notes/main.go @@ -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) + } +} diff --git a/go.mod b/go.mod index 1dcdd36..e8adae4 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/parser/errors.go b/parser/errors.go new file mode 100644 index 0000000..ba422cf --- /dev/null +++ b/parser/errors.go @@ -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) +} diff --git a/parser/parser.go b/parser/parser.go new file mode 100644 index 0000000..f80b014 --- /dev/null +++ b/parser/parser.go @@ -0,0 +1,122 @@ +package parser + +import ( + "bytes" + "fmt" + "io" + "regexp" +) + +var ( + defaultSectionReFmt = `(?s)(?P
## %s[^\n]*) +(?P.+?) +(?:## .+|$)` + 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 +} diff --git a/parser/parser_test.go b/parser/parser_test.go new file mode 100644 index 0000000..12dfdeb --- /dev/null +++ b/parser/parser_test.go @@ -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) + } + }) + } +}