Skip to content

Commit 201ae65

Browse files
committed
cmd/extract-notes: Implement note extraction per version
1 parent 522d403 commit 201ae65

File tree

5 files changed

+297
-0
lines changed

5 files changed

+297
-0
lines changed

cmd/changelog-extract-notes/main.go

+54
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package main
2+
3+
import (
4+
"flag"
5+
"fmt"
6+
"os"
7+
"path/filepath"
8+
9+
"github.com/hashicorp/go-changelog/parser"
10+
)
11+
12+
func main() {
13+
wd, err := os.Getwd()
14+
if err != nil {
15+
fmt.Fprintln(os.Stderr, err)
16+
os.Exit(1)
17+
}
18+
19+
var changelogPath string
20+
flag.StringVar(&changelogPath, "path", filepath.Join(wd, "CHANGELOG.md"), "path to the changelog file")
21+
22+
// extractVersion represents version to extract changelog for (e.g. 1.0.0)
23+
extractVersion := flag.Arg(0)
24+
flag.Parse()
25+
26+
if extractVersion == "" {
27+
fmt.Fprintf(os.Stderr, "Must specify version\n\n")
28+
flag.Usage()
29+
os.Exit(1)
30+
}
31+
32+
f, err := os.Open(changelogPath)
33+
if err != nil {
34+
fmt.Fprintf(os.Stderr, "failed to open file: %s", err)
35+
os.Exit(1)
36+
}
37+
38+
sp, err := parser.NewSectionParser(f)
39+
if err != nil {
40+
fmt.Fprintf(os.Stderr, "unable to read changelog file: %s", err)
41+
os.Exit(1)
42+
}
43+
s, err := sp.Section(extractVersion)
44+
if err != nil {
45+
fmt.Fprintln(os.Stderr, err.Error())
46+
os.Exit(1)
47+
}
48+
49+
_, err = os.Stdout.Write(s.Body)
50+
if err != nil {
51+
fmt.Fprintln(os.Stderr, err.Error())
52+
os.Exit(1)
53+
}
54+
}

go.mod

+1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ go 1.16
55
require (
66
github.com/go-git/go-billy/v5 v5.3.1
77
github.com/go-git/go-git/v5 v5.4.2
8+
github.com/google/go-cmp v0.3.0
89
github.com/google/go-github v17.0.0+incompatible
910
github.com/google/go-querystring v1.0.0 // indirect
1011
github.com/manifoldco/promptui v0.8.0

parser/errors.go

+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package parser
2+
3+
import "fmt"
4+
5+
type VersionNotFoundErr struct {
6+
Version string
7+
}
8+
9+
func (e *VersionNotFoundErr) Is(target error) bool {
10+
tErr, ok := target.(*VersionNotFoundErr)
11+
if !ok {
12+
return false
13+
}
14+
return tErr.Version == e.Version
15+
}
16+
17+
func (e *VersionNotFoundErr) Error() string {
18+
return fmt.Sprintf("version %s not found", e.Version)
19+
}

parser/parser.go

+122
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
package parser
2+
3+
import (
4+
"bytes"
5+
"fmt"
6+
"io"
7+
"regexp"
8+
)
9+
10+
var (
11+
defaultSectionReFmt = `(?s)(?P<header>## %s[^\n]*)
12+
(?P<body>.+?)
13+
(?:## .+|$)`
14+
headerMatchName = "header"
15+
bodyMatchName = "body"
16+
)
17+
18+
type SectionParser struct {
19+
RegexpFormat string
20+
content []byte
21+
}
22+
23+
func NewSectionParser(r io.Reader) (*SectionParser, error) {
24+
b, err := io.ReadAll(r)
25+
if err != nil {
26+
return nil, err
27+
}
28+
29+
return &SectionParser{
30+
RegexpFormat: defaultSectionReFmt,
31+
content: b,
32+
}, nil
33+
}
34+
35+
type SectionRange struct {
36+
HeaderRange *ByteRange
37+
BodyRange *ByteRange
38+
}
39+
40+
type ByteRange struct {
41+
From, To int
42+
}
43+
44+
func (p *SectionParser) regexpFormat() string {
45+
if p.RegexpFormat == "" {
46+
return defaultSectionReFmt
47+
}
48+
return p.RegexpFormat
49+
}
50+
51+
func (p *SectionParser) regexp(v string) (*regexp.Regexp, error) {
52+
escapedVersion := regexp.QuoteMeta(v)
53+
return regexp.Compile(fmt.Sprintf(p.regexpFormat(), escapedVersion))
54+
}
55+
56+
func (p *SectionParser) SectionRange(v string) (*SectionRange, error) {
57+
re, err := p.regexp(v)
58+
if err != nil {
59+
return nil, err
60+
}
61+
62+
loc := re.FindSubmatchIndex(p.content)
63+
if loc == nil {
64+
return nil, &VersionNotFoundErr{v}
65+
}
66+
67+
headerIdx, err := findSubexpIndexes(re, headerMatchName)
68+
if err != nil {
69+
return nil, err
70+
}
71+
72+
bodyIdx, err := findSubexpIndexes(re, bodyMatchName)
73+
if err != nil {
74+
return nil, err
75+
}
76+
77+
return &SectionRange{
78+
HeaderRange: &ByteRange{
79+
From: loc[headerIdx.from],
80+
To: loc[headerIdx.to],
81+
},
82+
BodyRange: &ByteRange{
83+
From: loc[bodyIdx.from],
84+
To: loc[bodyIdx.to],
85+
},
86+
}, nil
87+
}
88+
89+
type index struct {
90+
from, to int
91+
}
92+
93+
func findSubexpIndexes(re *regexp.Regexp, name string) (*index, error) {
94+
for i, seName := range re.SubexpNames() {
95+
if seName == name {
96+
from := i * 2
97+
return &index{from, from + 1}, nil
98+
}
99+
}
100+
101+
return nil, fmt.Errorf("subexpression %q not found", name)
102+
}
103+
104+
type Section struct {
105+
Header []byte
106+
Body []byte
107+
}
108+
109+
func (p *SectionParser) Section(v string) (*Section, error) {
110+
sr, err := p.SectionRange(v)
111+
if err != nil {
112+
return nil, err
113+
}
114+
115+
headerRng := sr.HeaderRange
116+
bodyRng := sr.BodyRange
117+
118+
return &Section{
119+
Header: bytes.TrimSpace(p.content[headerRng.From:headerRng.To]),
120+
Body: bytes.TrimSpace(p.content[bodyRng.From:bodyRng.To]),
121+
}, nil
122+
}

parser/parser_test.go

+101
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
package parser
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"strings"
7+
"testing"
8+
9+
"github.com/google/go-cmp/cmp"
10+
)
11+
12+
func TestParser_Section(t *testing.T) {
13+
testCases := []struct {
14+
name string
15+
content string
16+
version string
17+
18+
expectedSection *Section
19+
expectedErr error
20+
}{
21+
{
22+
"empty log",
23+
"",
24+
"0.12.0",
25+
nil,
26+
&VersionNotFoundErr{"0.12.0"},
27+
},
28+
{
29+
"version not found",
30+
`## 0.11.0
31+
32+
something
33+
34+
## 0.10.0
35+
36+
testing
37+
`,
38+
"0.12.0",
39+
nil,
40+
&VersionNotFoundErr{"0.12.0"},
41+
},
42+
{
43+
"matching unreleased version",
44+
`## 0.12.0 (Unreleased)
45+
46+
something
47+
48+
## 0.11.0
49+
50+
testing
51+
`,
52+
"0.12.0",
53+
&Section{
54+
Header: []byte("## 0.12.0 (Unreleased)"),
55+
Body: []byte("something"),
56+
},
57+
nil,
58+
},
59+
{
60+
"matching released version - top",
61+
`## 0.12.0
62+
matching text
63+
with newline
64+
65+
## 0.11.99
66+
67+
- something
68+
- else
69+
`,
70+
"0.12.0",
71+
&Section{
72+
Header: []byte("## 0.12.0"),
73+
Body: []byte(`matching text
74+
with newline`),
75+
},
76+
nil,
77+
},
78+
}
79+
for i, tc := range testCases {
80+
t.Run(fmt.Sprintf("%d-%s", i, tc.name), func(t *testing.T) {
81+
r := strings.NewReader(tc.content)
82+
p, err := NewSectionParser(r)
83+
if err != nil {
84+
t.Fatal(err)
85+
}
86+
s, err := p.Section(tc.version)
87+
if err == nil && tc.expectedErr != nil {
88+
t.Fatalf("expected error: %s", tc.expectedErr.Error())
89+
}
90+
91+
if !errors.Is(err, tc.expectedErr) {
92+
diff := cmp.Diff(tc.expectedErr, err)
93+
t.Fatalf("error doesn't match: %s", diff)
94+
}
95+
96+
if diff := cmp.Diff(tc.expectedSection, s); diff != "" {
97+
t.Fatalf("parsed section don't match: %s", diff)
98+
}
99+
})
100+
}
101+
}

0 commit comments

Comments
 (0)