Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
gitpod-io
GitHub Repository: gitpod-io/gitpod
Path: blob/main/dev/changelog/update.go
2492 views
1
// Copyright (c) 2021 Gitpod GmbH. All rights reserved.
2
// Licensed under the GNU Affero General Public License (AGPL).
3
// See License.AGPL.txt in the project root for license information.
4
//
5
// Based on https://github.com/leodido/rn2md with kind permission from the author
6
package main
7
8
import (
9
"bufio"
10
"context"
11
"fmt"
12
"os"
13
"regexp"
14
"sort"
15
"strconv"
16
"strings"
17
"time"
18
19
"github.com/google/go-github/v38/github"
20
logger "github.com/sirupsen/logrus"
21
"github.com/spf13/cobra"
22
)
23
24
type UpdateOptions struct {
25
Token string
26
ChangelogFile string
27
Org string
28
Repo string
29
Branch string
30
}
31
32
var opts = &UpdateOptions{}
33
34
var updateCommand = &cobra.Command{
35
Use: "update",
36
Long: "parses the latest entry from the existing changelog file, gets more recent PRs from GitHub and prepends their release note entries.",
37
Short: "Generate markdown for your changelogs from release-note blocks.",
38
Run: func(c *cobra.Command, args []string) {
39
existingNotes, lastPrNumber, lastPrMonth := ParseFile(opts.ChangelogFile)
40
client := NewClient(opts.Token)
41
notes, err := GetReleaseNotes(client, opts, lastPrNumber, lastPrMonth)
42
if err != nil {
43
logger.WithError(err).Fatal("error retrieving PRs")
44
}
45
if len(notes) == 0 {
46
logger.Infof("No new PRs, changelog is up-to-date")
47
return
48
}
49
logger.Infof("Adding %d release note entries", len(notes))
50
WriteFile(opts.ChangelogFile, notes, existingNotes, lastPrMonth)
51
},
52
}
53
54
func init() {
55
// Setup updateFlags before the command is initialized
56
updateFlags := updateCommand.PersistentFlags()
57
updateFlags.StringVarP(&opts.Org, "org", "o", opts.Org, "the github organization")
58
updateFlags.StringVarP(&opts.Repo, "repo", "r", opts.Repo, "the github repository name")
59
updateFlags.StringVarP(&opts.Branch, "branch", "b", "main", "the target branch you want to filter by the pull requests")
60
updateFlags.StringVarP(&opts.ChangelogFile, "file", "f", "CHANGELOG.md", "the changelog file")
61
updateFlags.StringVarP(&opts.Token, "token", "t", opts.Token, "a GitHub personal API token to perform authenticated requests")
62
rootCommand.AddCommand(updateCommand)
63
}
64
65
var (
66
noteLineRegexp = regexp.MustCompile(`[-*]\s.*\s\(\[#(\d*)\]\(`)
67
dateLineRegexp = regexp.MustCompile(`## (\w* \d*)`)
68
)
69
70
func ParseFile(path string) (existingNotes []string, lastPrNumber int, lastPrMonth time.Time) {
71
lastPrNumber = 0
72
lastPrMonth = time.Unix(0, 0)
73
if _, err := os.Stat(path); os.IsNotExist(err) {
74
return
75
}
76
77
file, err := os.Open(path)
78
if err != nil {
79
logger.Fatal(err)
80
}
81
defer file.Close()
82
83
scanner := bufio.NewScanner(file)
84
for scanner.Scan() {
85
dateMatch := dateLineRegexp.FindStringSubmatch(scanner.Text())
86
if len(dateMatch) > 1 && lastPrMonth == time.Unix(0, 0) {
87
lastPrMonth, err = time.Parse("January 2006", dateMatch[1])
88
if err != nil {
89
logger.Warnf("Ignoring invalid date line %s", dateMatch[1])
90
} else {
91
logger.Infof("Last PR month %s", lastPrMonth.Format("January 2006"))
92
}
93
}
94
prMatch := noteLineRegexp.FindStringSubmatch(scanner.Text())
95
if len(prMatch) > 1 && lastPrNumber == 0 {
96
lastPrNumber, err = strconv.Atoi(prMatch[1])
97
if err != nil {
98
logger.Warnf("Ignoring invalid PR number %s", prMatch[1])
99
} else {
100
logger.Infof("Last PR number #%d", lastPrNumber)
101
}
102
}
103
if lastPrNumber != 0 {
104
existingNotes = append(existingNotes, scanner.Text())
105
}
106
}
107
if err := scanner.Err(); err != nil {
108
logger.Fatal(err)
109
}
110
return
111
}
112
113
// ReleaseNote ...
114
115
type ReleaseNote struct {
116
Breaking bool
117
Description string
118
URI string
119
Num int
120
Authors map[string]Author
121
MergedAt time.Time
122
}
123
124
type Author struct {
125
Login string
126
URL string
127
}
128
129
type void struct{}
130
131
var member void
132
var releaseNoteRegexp = regexp.MustCompile("(?s)```release-note\\b(.+?)```")
133
134
const defaultGitHubBaseURI = "https://github.com"
135
136
// Get returns the list of release notes found for the given parameters.
137
func GetReleaseNotes(c *github.Client, opts *UpdateOptions, lastPrNr int, lastPrMonth time.Time) ([]ReleaseNote, error) {
138
var (
139
ctx = context.Background()
140
releaseNotes = []ReleaseNote{}
141
processed = make(map[int]void)
142
)
143
lastPrDate := lastPrMonth
144
if lastPrNr != 0 {
145
lastPr, _, err := c.PullRequests.Get(ctx, opts.Org, opts.Repo, lastPrNr)
146
if err != nil {
147
return nil, err
148
}
149
lastPrDate = lastPr.GetMergedAt()
150
}
151
logger.Infof("Will stop processing for PRs updated before %s", lastPrDate.Format("2006-01-02 15:04:05"))
152
153
listingOpts := &github.PullRequestListOptions{
154
State: "closed",
155
Base: opts.Branch,
156
Sort: "updated",
157
Direction: "desc",
158
ListOptions: github.ListOptions{
159
// All GitHub paginated queries start at page 1 !?
160
// https://docs.github.com/en/rest/guides/traversing-with-pagination
161
Page: 1,
162
},
163
}
164
165
for {
166
logger.Infof("Querying PRs from GitHub, page %d", listingOpts.ListOptions.Page)
167
prs, response, err := c.PullRequests.List(ctx, opts.Org, opts.Repo, listingOpts)
168
if _, ok := err.(*github.RateLimitError); ok {
169
return nil, fmt.Errorf("hit rate limiting")
170
}
171
if err != nil {
172
return nil, err
173
}
174
logger.Infof("Received %d PRs", len(prs))
175
for _, p := range prs {
176
num := p.GetNumber()
177
if _, exists := processed[num]; exists {
178
continue
179
}
180
// the changelog is sorted by merge date, but the GitHub results can only be
181
// sorted by last update. So it's a bit tricky to find out when to stop, as we
182
// sometimes comment on already merged PRs.
183
if p.GetUpdatedAt().Before(lastPrDate) {
184
// PRs are sorted by last update, so from here all PRs have already been
185
// processed in prior runs
186
return releaseNotes, nil
187
}
188
if p.GetMergedAt().Before(lastPrDate) || p.GetMergedAt().Equal(lastPrDate) {
189
// PR has already been processed, but it could have been updated after merge
190
continue
191
}
192
193
processed[num] = member
194
195
isMerged, _, err := c.PullRequests.IsMerged(ctx, opts.Org, opts.Repo, num)
196
if _, ok := err.(*github.RateLimitError); ok {
197
return nil, fmt.Errorf("hit rate limiting")
198
}
199
if err != nil {
200
return nil, fmt.Errorf("error detecting if PR %d is merged or not", num)
201
}
202
if !isMerged {
203
// It means PR has been closed but not merged in
204
continue
205
}
206
207
authors, err := GetAuthors(c, ctx, opts, num)
208
if err != nil {
209
return nil, fmt.Errorf("error getting authors of #%d", num)
210
}
211
res := releaseNoteRegexp.FindStringSubmatch(p.GetBody())
212
if len(res) == 0 {
213
// legacy mode for pre-changelog automation PRs
214
rn := ReleaseNote{
215
Breaking: false,
216
Description: p.GetTitle(),
217
URI: fmt.Sprintf("%s/%s/%s/pull/%d", defaultGitHubBaseURI, opts.Org, opts.Repo, num),
218
Num: num,
219
Authors: authors,
220
MergedAt: p.GetMergedAt(),
221
}
222
releaseNotes = append(releaseNotes, rn)
223
continue
224
}
225
note := strings.TrimSpace(res[1])
226
if strings.ToUpper(note) == "NONE" {
227
continue
228
}
229
notes := strings.Split(note, "\n")
230
for _, n := range notes {
231
n = strings.Trim(n, "\r")
232
breaking := false
233
if strings.HasPrefix(n, "!") {
234
breaking = true
235
n = strings.TrimSpace(n[1:])
236
}
237
rn := ReleaseNote{
238
Breaking: breaking,
239
Description: n,
240
URI: fmt.Sprintf("%s/%s/%s/pull/%d", defaultGitHubBaseURI, opts.Org, opts.Repo, num),
241
Num: num,
242
Authors: authors,
243
MergedAt: p.GetMergedAt(),
244
}
245
releaseNotes = append(releaseNotes, rn)
246
}
247
}
248
if response.NextPage == 0 {
249
break
250
}
251
listingOpts.ListOptions.Page = response.NextPage
252
}
253
return releaseNotes, nil
254
}
255
256
func GetAuthors(c *github.Client, ctx context.Context, opts *UpdateOptions, prNum int) (map[string]Author, error) {
257
authors := make(map[string]Author)
258
listOpts := &github.ListOptions{
259
Page: 1,
260
}
261
for {
262
commits, response, err := c.PullRequests.ListCommits(ctx, opts.Org, opts.Repo, prNum, listOpts)
263
if _, ok := err.(*github.RateLimitError); ok {
264
return nil, fmt.Errorf("hit rate limiting")
265
}
266
if err != nil {
267
return nil, err
268
}
269
for _, commit := range commits {
270
authors[commit.GetAuthor().GetLogin()] = Author{
271
Login: commit.GetAuthor().GetLogin(),
272
URL: commit.GetAuthor().GetHTMLURL(),
273
}
274
}
275
if response.NextPage == 0 {
276
return authors, nil
277
}
278
listOpts.Page = response.NextPage
279
}
280
}
281
282
func WriteFile(path string, notes []ReleaseNote, existingNotes []string, lastPrDate time.Time) {
283
sort.SliceStable(notes, func(i, j int) bool {
284
return notes[i].MergedAt.After(notes[j].MergedAt)
285
})
286
file, err := os.Create(path)
287
if err != nil {
288
logger.Fatalf("Cannot write file %s", path)
289
}
290
defer file.Close()
291
fmt.Fprintln(file, "# Change Log")
292
lastMonth := ""
293
currentMonth := ""
294
for _, note := range notes {
295
currentMonth = note.MergedAt.Format("January 2006")
296
if currentMonth != lastMonth {
297
fmt.Fprintln(file, "\n##", currentMonth)
298
lastMonth = currentMonth
299
}
300
breaking := ""
301
if note.Breaking {
302
breaking = " *BREAKING*"
303
}
304
authors := make([]string, len(note.Authors))
305
i := 0
306
for _, author := range note.Authors {
307
authors[i] = fmt.Sprintf("[@%s](%s)", author.Login, author.URL)
308
i++
309
}
310
sort.Strings(authors)
311
line := fmt.Sprintf("-%s %s ([#%d](%s)) - %s", breaking, note.Description, note.Num, note.URI, strings.Join(authors, ", "))
312
fmt.Fprintln(file, line)
313
}
314
315
if len(existingNotes) > 0 {
316
currentMonth = lastPrDate.Format("January 2006")
317
if currentMonth != lastMonth {
318
fmt.Fprintln(file, "\n## ", currentMonth)
319
}
320
for _, note := range existingNotes {
321
fmt.Fprintln(file, note)
322
}
323
}
324
}
325
326