Skip to content

Commit 2436fba

Browse files
committed
Make it a Go app
1 parent 5c31f27 commit 2436fba

File tree

9 files changed

+520
-233
lines changed

9 files changed

+520
-233
lines changed

.github/workflows/release.yml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
name: release
2+
3+
on:
4+
push:
5+
tags:
6+
- '*'
7+
8+
permissions:
9+
contents: write
10+
11+
jobs:
12+
release:
13+
runs-on: ubuntu-latest
14+
steps:
15+
- uses: actions/checkout@v3
16+
- uses: cli/[email protected]

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/gh-changelog

README.md

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,24 @@
11
## `gh changelog`
22

3-
`gh changelog` is a GitHub CLI extension to generate a changelog from the PRs
4-
of a milestone.
3+
`gh changelog` is a GitHub CLI extension to generate a changelog from the pull
4+
requests of a milestone.
55

6-
## Usage
7-
8-
```bash
9-
$ gh changelog --help
6+
## Installation
107

11-
Usage: gh changelog {<milestone>} [options]
8+
Make sure you have `gh` and `git` installed. Then run:
129

13-
Options:
14-
-u, --unreleased Set a version number for the unreleased changes
15-
-h, --help Display the help information
10+
```bash
11+
$ gh extension install leofeyer/gh-changelog
1612
```
1713

18-
## Installation
14+
## Usage
1915

20-
Make sure you have `gh` and `git` installed.
16+
```bash
17+
$ gh changelog 1.2
18+
```
2119

22-
Then run:
20+
You can optionally specify a version number for unreleased changes:
2321

2422
```bash
25-
$ gh extension install leofeyer/gh-changelog
23+
$ gh changelog 1.2 1.2.6
2624
```

api/changelog.go

Lines changed: 311 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,311 @@
1+
package api
2+
3+
import (
4+
"encoding/csv"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"os/exec"
9+
"sort"
10+
"strings"
11+
"time"
12+
13+
"github.com/briandowns/spinner"
14+
"github.com/cli/go-gh"
15+
"github.com/leofeyer/gh-changelog/util"
16+
"github.com/natefinch/atomic"
17+
)
18+
19+
type Item struct {
20+
Time string
21+
Title string
22+
Type string
23+
Number int
24+
Url string
25+
Author string
26+
}
27+
28+
func Changelog(milestone string, version string) error {
29+
s := spinner.New(spinner.CharSets[11], 120*time.Millisecond)
30+
s.Start()
31+
32+
owner, err := getOwner()
33+
if err != nil {
34+
s.Stop()
35+
return err
36+
}
37+
38+
repo, err := getRepo()
39+
if err != nil {
40+
s.Stop()
41+
return err
42+
}
43+
44+
items, err := getItems(milestone, owner, repo)
45+
if err != nil {
46+
s.Stop()
47+
return err
48+
}
49+
50+
r := strings.NewReader(getContent(items, owner, repo, version))
51+
atomic.WriteFile("./CHANGELOG.md", r)
52+
53+
s.Stop()
54+
fmt.Println("The CHANGELOG.md file has been updated.")
55+
return nil
56+
}
57+
58+
func getItems(milestone string, owner string, repo string) ([]Item, error) {
59+
tags, err := getTags(milestone)
60+
if err != nil {
61+
return nil, err
62+
}
63+
64+
features, err := search(milestone, "feature", owner, repo)
65+
if err != nil {
66+
return nil, err
67+
}
68+
69+
issues, err := search(milestone, "bug", owner, repo)
70+
if err != nil {
71+
return nil, err
72+
}
73+
74+
var items []Item
75+
items = append(items, tags...)
76+
items = append(items, features...)
77+
items = append(items, issues...)
78+
79+
sort.Slice(items, func(i, j int) bool {
80+
return items[i].Time > items[j].Time // reverse sort
81+
})
82+
83+
return items, nil
84+
}
85+
86+
func getOwner() (string, error) {
87+
data, _, err := gh.Exec("repo", "view", "--json", "owner")
88+
if err != nil {
89+
return "", err
90+
}
91+
92+
type Result struct {
93+
Owner struct {
94+
Login string `json:"login"`
95+
}
96+
}
97+
98+
var r Result
99+
100+
err = json.Unmarshal(data.Bytes(), &r)
101+
if err != nil {
102+
return "", err
103+
}
104+
105+
return r.Owner.Login, nil
106+
}
107+
108+
func getRepo() (string, error) {
109+
data, _, err := gh.Exec("repo", "view", "--json", "name")
110+
if err != nil {
111+
return "", err
112+
}
113+
114+
type Result struct {
115+
Name string `json:"name"`
116+
}
117+
118+
var r Result
119+
120+
err = json.Unmarshal(data.Bytes(), &r)
121+
if err != nil {
122+
return "", err
123+
}
124+
125+
return r.Name, nil
126+
}
127+
128+
func getTags(milestone string) ([]Item, error) {
129+
cmd := "TZ=UTC0 git tag"
130+
cmd += " --list " + milestone + ".*"
131+
cmd += " --sort=-creatordate"
132+
cmd += " --format '%(creatordate:format-local:%Y-%m-%dT%H:%M:%SZ),%(refname:short)'"
133+
134+
out, err := exec.Command("bash", "-c", cmd).Output()
135+
if err != nil {
136+
return nil, err
137+
}
138+
139+
var items []Item
140+
r := csv.NewReader(strings.NewReader(fmt.Sprintf("%s", out)))
141+
142+
for {
143+
fields, err := r.Read()
144+
if err == io.EOF {
145+
break
146+
}
147+
148+
if err != nil {
149+
return nil, err
150+
}
151+
152+
var item Item
153+
item.Time = fields[0]
154+
item.Title = fields[1]
155+
item.Type = "tag"
156+
157+
items = append(items, item)
158+
}
159+
160+
return items, nil
161+
}
162+
163+
func search(milestone string, label string, owner string, repo string) ([]Item, error) {
164+
var args []string
165+
args = append(args, "search", "prs")
166+
args = append(args, "--json", "number,title,author,url,closedAt")
167+
args = append(args, "--owner", owner)
168+
args = append(args, "--repo", repo)
169+
args = append(args, "--milestone", milestone)
170+
args = append(args, "--merged")
171+
args = append(args, "--limit", "1000")
172+
args = append(args, "--label", label)
173+
174+
data, _, err := gh.Exec(args...)
175+
if err != nil {
176+
return nil, err
177+
}
178+
179+
type PullRequest struct {
180+
Time string `json:"closedAt"`
181+
Title string `json:"title"`
182+
Number int `json:"number"`
183+
Url string `json:"url"`
184+
Author struct {
185+
Login string `json:"login"`
186+
}
187+
}
188+
189+
var r []PullRequest
190+
191+
err = json.Unmarshal(data.Bytes(), &r)
192+
if err != nil {
193+
return nil, err
194+
}
195+
196+
var items []Item
197+
198+
for i := 0; i < len(r); i++ {
199+
var item Item
200+
item.Time = r[i].Time
201+
item.Title = r[i].Title
202+
item.Type = label
203+
item.Number = r[i].Number
204+
item.Url = r[i].Url
205+
item.Author = r[i].Author.Login
206+
207+
items = append(items, item)
208+
}
209+
210+
return items, nil
211+
}
212+
213+
func getContent(items []Item, owner string, repo string, version string) string {
214+
var tags []string
215+
var features []Item
216+
var issues []Item
217+
218+
users := make(map[string]string)
219+
prs := make(map[int]string)
220+
url := "https://github.com/" + owner + "/" + repo
221+
222+
content := "# Changelog\n\nThis project adheres to [Semantic Versioning].\n\n"
223+
content += fmt.Sprintf("## [%s] (%s)\n", version, time.Now().Format("2006-01-02"))
224+
225+
for i := 0; i < len(items); i++ {
226+
if items[i].Type == "tag" {
227+
content += addSection(features, issues)
228+
content += fmt.Sprintf("\n## [%s] (%s)\n", items[i].Title, items[i].Time[0:10])
229+
230+
tags = append(tags, fmt.Sprintf("[%s]: %s/releases/tag/%[1]s\n", items[i].Title, url))
231+
features = features[:0]
232+
issues = issues[:0]
233+
} else {
234+
if items[i].Type == "feature" {
235+
features = append(features, items[i])
236+
} else if items[i].Type == "bug" {
237+
issues = append(issues, items[i])
238+
}
239+
240+
users[items[i].Author] = fmt.Sprintf("[%s]: https://github.com/%[1]s\n", items[i].Author)
241+
prs[items[i].Number] = fmt.Sprintf("[#%d]: %s/pull/%[1]d\n", items[i].Number, url)
242+
}
243+
}
244+
245+
content += addSection(features, issues)
246+
content += "\n[Semantic Versioning]: https://semver.org/spec/v2.0.0.html\n"
247+
248+
for i := 0; i < len(tags); i++ {
249+
content += tags[i]
250+
}
251+
252+
for _, k := range getUserKeys(users) {
253+
content += users[k]
254+
}
255+
256+
for _, k := range getPrKeys(prs) {
257+
content += prs[k]
258+
}
259+
260+
return content
261+
}
262+
263+
func addSection(features []Item, issues []Item) string {
264+
r := ""
265+
266+
if len(features) > 0 {
267+
r += "\n**New features:**\n\n"
268+
269+
for j := 0; j < len(features); j++ {
270+
r += fmt.Sprintf("- [#%d] %s ([%s])\n", features[j].Number, features[j].Title, features[j].Author)
271+
}
272+
273+
features = features[:0]
274+
}
275+
276+
if len(issues) > 0 {
277+
r += "\n**Fixed issues:**\n\n"
278+
279+
for j := 0; j < len(issues); j++ {
280+
r += fmt.Sprintf("- [#%d] %s ([%s])\n", issues[j].Number, issues[j].Title, issues[j].Author)
281+
}
282+
283+
issues = issues[:0]
284+
}
285+
286+
return r
287+
}
288+
289+
func getUserKeys(users map[string]string) []string {
290+
keys := make([]string, 0, len(users))
291+
292+
for k := range users {
293+
keys = append(keys, k)
294+
}
295+
296+
sort.Slice(keys, func(i, j int) bool { return util.SortCaseInsensitive(keys[i], keys[j]) })
297+
298+
return keys
299+
}
300+
301+
func getPrKeys(prs map[int]string) []int {
302+
keys := make([]int, 0, len(prs))
303+
304+
for k := range prs {
305+
keys = append(keys, k)
306+
}
307+
308+
sort.Ints(keys)
309+
310+
return keys
311+
}

0 commit comments

Comments
 (0)