mirror of
https://github.com/gitpod-io/gitpod.git
synced 2025-12-08 17:36:30 +00:00
325 lines
9.3 KiB
Go
325 lines
9.3 KiB
Go
// Copyright (c) 2021 Gitpod GmbH. All rights reserved.
|
|
// Licensed under the GNU Affero General Public License (AGPL).
|
|
// See License.AGPL.txt in the project root for license information.
|
|
//
|
|
// Based on https://github.com/leodido/rn2md with kind permission from the author
|
|
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/go-github/v38/github"
|
|
logger "github.com/sirupsen/logrus"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
type UpdateOptions struct {
|
|
Token string
|
|
ChangelogFile string
|
|
Org string
|
|
Repo string
|
|
Branch string
|
|
}
|
|
|
|
var opts = &UpdateOptions{}
|
|
|
|
var updateCommand = &cobra.Command{
|
|
Use: "update",
|
|
Long: "parses the latest entry from the existing changelog file, gets more recent PRs from GitHub and prepends their release note entries.",
|
|
Short: "Generate markdown for your changelogs from release-note blocks.",
|
|
Run: func(c *cobra.Command, args []string) {
|
|
existingNotes, lastPrNumber, lastPrMonth := ParseFile(opts.ChangelogFile)
|
|
client := NewClient(opts.Token)
|
|
notes, err := GetReleaseNotes(client, opts, lastPrNumber, lastPrMonth)
|
|
if err != nil {
|
|
logger.WithError(err).Fatal("error retrieving PRs")
|
|
}
|
|
if len(notes) == 0 {
|
|
logger.Infof("No new PRs, changelog is up-to-date")
|
|
return
|
|
}
|
|
logger.Infof("Adding %d release note entries", len(notes))
|
|
WriteFile(opts.ChangelogFile, notes, existingNotes, lastPrMonth)
|
|
},
|
|
}
|
|
|
|
func init() {
|
|
// Setup updateFlags before the command is initialized
|
|
updateFlags := updateCommand.PersistentFlags()
|
|
updateFlags.StringVarP(&opts.Org, "org", "o", opts.Org, "the github organization")
|
|
updateFlags.StringVarP(&opts.Repo, "repo", "r", opts.Repo, "the github repository name")
|
|
updateFlags.StringVarP(&opts.Branch, "branch", "b", "main", "the target branch you want to filter by the pull requests")
|
|
updateFlags.StringVarP(&opts.ChangelogFile, "file", "f", "CHANGELOG.md", "the changelog file")
|
|
updateFlags.StringVarP(&opts.Token, "token", "t", opts.Token, "a GitHub personal API token to perform authenticated requests")
|
|
rootCommand.AddCommand(updateCommand)
|
|
}
|
|
|
|
var (
|
|
noteLineRegexp = regexp.MustCompile(`[-*]\s.*\s\(\[#(\d*)\]\(`)
|
|
dateLineRegexp = regexp.MustCompile(`## (\w* \d*)`)
|
|
)
|
|
|
|
func ParseFile(path string) (existingNotes []string, lastPrNumber int, lastPrMonth time.Time) {
|
|
lastPrNumber = 0
|
|
lastPrMonth = time.Unix(0, 0)
|
|
if _, err := os.Stat(path); os.IsNotExist(err) {
|
|
return
|
|
}
|
|
|
|
file, err := os.Open(path)
|
|
if err != nil {
|
|
logger.Fatal(err)
|
|
}
|
|
defer file.Close()
|
|
|
|
scanner := bufio.NewScanner(file)
|
|
for scanner.Scan() {
|
|
dateMatch := dateLineRegexp.FindStringSubmatch(scanner.Text())
|
|
if len(dateMatch) > 1 && lastPrMonth == time.Unix(0, 0) {
|
|
lastPrMonth, err = time.Parse("January 2006", dateMatch[1])
|
|
if err != nil {
|
|
logger.Warnf("Ignoring invalid date line %s", dateMatch[1])
|
|
} else {
|
|
logger.Infof("Last PR month %s", lastPrMonth.Format("January 2006"))
|
|
}
|
|
}
|
|
prMatch := noteLineRegexp.FindStringSubmatch(scanner.Text())
|
|
if len(prMatch) > 1 && lastPrNumber == 0 {
|
|
lastPrNumber, err = strconv.Atoi(prMatch[1])
|
|
if err != nil {
|
|
logger.Warnf("Ignoring invalid PR number %s", prMatch[1])
|
|
} else {
|
|
logger.Infof("Last PR number #%d", lastPrNumber)
|
|
}
|
|
}
|
|
if lastPrNumber != 0 {
|
|
existingNotes = append(existingNotes, scanner.Text())
|
|
}
|
|
}
|
|
if err := scanner.Err(); err != nil {
|
|
logger.Fatal(err)
|
|
}
|
|
return
|
|
}
|
|
|
|
// ReleaseNote ...
|
|
|
|
type ReleaseNote struct {
|
|
Breaking bool
|
|
Description string
|
|
URI string
|
|
Num int
|
|
Authors map[string]Author
|
|
MergedAt time.Time
|
|
}
|
|
|
|
type Author struct {
|
|
Login string
|
|
URL string
|
|
}
|
|
|
|
type void struct{}
|
|
|
|
var member void
|
|
var releaseNoteRegexp = regexp.MustCompile("(?s)```release-note\\b(.+?)```")
|
|
|
|
const defaultGitHubBaseURI = "https://github.com"
|
|
|
|
// Get returns the list of release notes found for the given parameters.
|
|
func GetReleaseNotes(c *github.Client, opts *UpdateOptions, lastPrNr int, lastPrMonth time.Time) ([]ReleaseNote, error) {
|
|
var (
|
|
ctx = context.Background()
|
|
releaseNotes = []ReleaseNote{}
|
|
processed = make(map[int]void)
|
|
)
|
|
lastPrDate := lastPrMonth
|
|
if lastPrNr != 0 {
|
|
lastPr, _, err := c.PullRequests.Get(ctx, opts.Org, opts.Repo, lastPrNr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
lastPrDate = lastPr.GetMergedAt()
|
|
}
|
|
logger.Infof("Will stop processing for PRs updated before %s", lastPrDate.Format("2006-01-02 15:04:05"))
|
|
|
|
listingOpts := &github.PullRequestListOptions{
|
|
State: "closed",
|
|
Base: opts.Branch,
|
|
Sort: "updated",
|
|
Direction: "desc",
|
|
ListOptions: github.ListOptions{
|
|
// All GitHub paginated queries start at page 1 !?
|
|
// https://docs.github.com/en/rest/guides/traversing-with-pagination
|
|
Page: 1,
|
|
},
|
|
}
|
|
|
|
for {
|
|
logger.Infof("Querying PRs from GitHub, page %d", listingOpts.ListOptions.Page)
|
|
prs, response, err := c.PullRequests.List(ctx, opts.Org, opts.Repo, listingOpts)
|
|
if _, ok := err.(*github.RateLimitError); ok {
|
|
return nil, fmt.Errorf("hit rate limiting")
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
logger.Infof("Received %d PRs", len(prs))
|
|
for _, p := range prs {
|
|
num := p.GetNumber()
|
|
if _, exists := processed[num]; exists {
|
|
continue
|
|
}
|
|
// the changelog is sorted by merge date, but the GitHub results can only be
|
|
// sorted by last update. So it's a bit tricky to find out when to stop, as we
|
|
// sometimes comment on already merged PRs.
|
|
if p.GetUpdatedAt().Before(lastPrDate) {
|
|
// PRs are sorted by last update, so from here all PRs have already been
|
|
// processed in prior runs
|
|
return releaseNotes, nil
|
|
}
|
|
if p.GetMergedAt().Before(lastPrDate) || p.GetMergedAt().Equal(lastPrDate) {
|
|
// PR has already been processed, but it could have been updated after merge
|
|
continue
|
|
}
|
|
|
|
processed[num] = member
|
|
|
|
isMerged, _, err := c.PullRequests.IsMerged(ctx, opts.Org, opts.Repo, num)
|
|
if _, ok := err.(*github.RateLimitError); ok {
|
|
return nil, fmt.Errorf("hit rate limiting")
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error detecting if PR %d is merged or not", num)
|
|
}
|
|
if !isMerged {
|
|
// It means PR has been closed but not merged in
|
|
continue
|
|
}
|
|
|
|
authors, err := GetAuthors(c, ctx, opts, num)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error getting authors of #%d", num)
|
|
}
|
|
res := releaseNoteRegexp.FindStringSubmatch(p.GetBody())
|
|
if len(res) == 0 {
|
|
// legacy mode for pre-changelog automation PRs
|
|
rn := ReleaseNote{
|
|
Breaking: false,
|
|
Description: p.GetTitle(),
|
|
URI: fmt.Sprintf("%s/%s/%s/pull/%d", defaultGitHubBaseURI, opts.Org, opts.Repo, num),
|
|
Num: num,
|
|
Authors: authors,
|
|
MergedAt: p.GetMergedAt(),
|
|
}
|
|
releaseNotes = append(releaseNotes, rn)
|
|
continue
|
|
}
|
|
note := strings.TrimSpace(res[1])
|
|
if strings.ToUpper(note) == "NONE" {
|
|
continue
|
|
}
|
|
notes := strings.Split(note, "\n")
|
|
for _, n := range notes {
|
|
n = strings.Trim(n, "\r")
|
|
breaking := false
|
|
if strings.HasPrefix(n, "!") {
|
|
breaking = true
|
|
n = strings.TrimSpace(n[1:])
|
|
}
|
|
rn := ReleaseNote{
|
|
Breaking: breaking,
|
|
Description: n,
|
|
URI: fmt.Sprintf("%s/%s/%s/pull/%d", defaultGitHubBaseURI, opts.Org, opts.Repo, num),
|
|
Num: num,
|
|
Authors: authors,
|
|
MergedAt: p.GetMergedAt(),
|
|
}
|
|
releaseNotes = append(releaseNotes, rn)
|
|
}
|
|
}
|
|
if response.NextPage == 0 {
|
|
break
|
|
}
|
|
listingOpts.ListOptions.Page = response.NextPage
|
|
}
|
|
return releaseNotes, nil
|
|
}
|
|
|
|
func GetAuthors(c *github.Client, ctx context.Context, opts *UpdateOptions, prNum int) (map[string]Author, error) {
|
|
authors := make(map[string]Author)
|
|
listOpts := &github.ListOptions{
|
|
Page: 1,
|
|
}
|
|
for {
|
|
commits, response, err := c.PullRequests.ListCommits(ctx, opts.Org, opts.Repo, prNum, listOpts)
|
|
if _, ok := err.(*github.RateLimitError); ok {
|
|
return nil, fmt.Errorf("hit rate limiting")
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, commit := range commits {
|
|
authors[commit.GetAuthor().GetLogin()] = Author{
|
|
Login: commit.GetAuthor().GetLogin(),
|
|
URL: commit.GetAuthor().GetHTMLURL(),
|
|
}
|
|
}
|
|
if response.NextPage == 0 {
|
|
return authors, nil
|
|
}
|
|
listOpts.Page = response.NextPage
|
|
}
|
|
}
|
|
|
|
func WriteFile(path string, notes []ReleaseNote, existingNotes []string, lastPrDate time.Time) {
|
|
sort.SliceStable(notes, func(i, j int) bool {
|
|
return notes[i].MergedAt.After(notes[j].MergedAt)
|
|
})
|
|
file, err := os.Create(path)
|
|
if err != nil {
|
|
logger.Fatalf("Cannot write file %s", path)
|
|
}
|
|
defer file.Close()
|
|
fmt.Fprintln(file, "# Change Log")
|
|
lastMonth := ""
|
|
currentMonth := ""
|
|
for _, note := range notes {
|
|
currentMonth = note.MergedAt.Format("January 2006")
|
|
if currentMonth != lastMonth {
|
|
fmt.Fprintln(file, "\n##", currentMonth)
|
|
lastMonth = currentMonth
|
|
}
|
|
breaking := ""
|
|
if note.Breaking {
|
|
breaking = " *BREAKING*"
|
|
}
|
|
authors := make([]string, len(note.Authors))
|
|
i := 0
|
|
for _, author := range note.Authors {
|
|
authors[i] = fmt.Sprintf("[@%s](%s)", author.Login, author.URL)
|
|
i++
|
|
}
|
|
sort.Strings(authors)
|
|
line := fmt.Sprintf("-%s %s ([#%d](%s)) - %s", breaking, note.Description, note.Num, note.URI, strings.Join(authors, ", "))
|
|
fmt.Fprintln(file, line)
|
|
}
|
|
|
|
if len(existingNotes) > 0 {
|
|
currentMonth = lastPrDate.Format("January 2006")
|
|
if currentMonth != lastMonth {
|
|
fmt.Fprintln(file, "\n## ", currentMonth)
|
|
}
|
|
for _, note := range existingNotes {
|
|
fmt.Fprintln(file, note)
|
|
}
|
|
}
|
|
}
|