Skip to content

Commit 07bb377

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

File tree

5 files changed

+283
-0
lines changed

5 files changed

+283
-0
lines changed

cmd/changelog-extract-notes/main.go

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

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

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

parser/parser_test.go

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

0 commit comments

Comments
 (0)