├── .gitignore ├── pkg └── releasenotes │ ├── statistics.go │ ├── print.go │ └── releasenotes.go ├── main.go ├── .goreleaser.yml ├── validate ├── semver.go └── validate.go ├── .github └── workflows │ ├── ci.yml │ ├── test.yml │ └── release.yml ├── go.mod ├── action.yml ├── cmd ├── opts.go └── root.go ├── README.md └── go.sum /.gitignore: -------------------------------------------------------------------------------- 1 | rn2md -------------------------------------------------------------------------------- /pkg/releasenotes/statistics.go: -------------------------------------------------------------------------------- 1 | package releasenotes 2 | 3 | // Statistics represents counters about the merged in PRs. 4 | type Statistics struct { 5 | total int64 6 | nonFacing int64 7 | authors map[string]int64 8 | } 9 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/leodido/rn2md/cmd" 5 | logger "github.com/sirupsen/logrus" 6 | ) 7 | 8 | func main() { 9 | if err := cmd.Run(); err != nil { 10 | logger.WithError(err).Fatal("exiting") 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | project_name: rn2md 2 | builds: 3 | - id: "rn2md" 4 | goos: 5 | - linux 6 | goarch: 7 | - amd64 8 | - arm64 9 | main: . 10 | flags: 11 | - -buildmode=pie 12 | env: 13 | - CGO_ENABLED=0 14 | binary: rn2md 15 | 16 | release: 17 | github: 18 | prerelease: auto 19 | -------------------------------------------------------------------------------- /validate/semver.go: -------------------------------------------------------------------------------- 1 | package validate 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | 7 | "github.com/Masterminds/semver/v3" 8 | "github.com/go-playground/validator/v10" 9 | ) 10 | 11 | func isSemVer(fl validator.FieldLevel) bool { 12 | field := fl.Field() 13 | 14 | switch field.Kind() { 15 | case reflect.String: 16 | _, err := semver.NewVersion(field.String()) 17 | return err == nil 18 | } 19 | 20 | panic(fmt.Sprintf("Bad field type %T", field.Interface())) 21 | } -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI build 2 | on: 3 | pull_request: 4 | push: 5 | branches: 6 | - master 7 | workflow_dispatch: 8 | 9 | concurrency: 10 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 11 | cancel-in-progress: true 12 | 13 | jobs: 14 | build-and-test: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v4 19 | 20 | - name: Setup Go 21 | uses: actions/setup-go@v1 22 | with: 23 | go-version: 1.21 24 | 25 | - name: Build 26 | run: go build . 27 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test composite action 2 | on: 3 | pull_request: 4 | paths: 5 | - 'action.yml' 6 | workflow_dispatch: 7 | 8 | jobs: 9 | test-composite-action: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v4 14 | 15 | - name: generate release notes for Falco 0.36.0 16 | id: generate 17 | uses: ./ 18 | with: 19 | branch: master 20 | repo: falcosecurity/falco 21 | output: test.md 22 | milestone: '0.36.0' 23 | 24 | - name: show output file 25 | run: cat test.md 26 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | permissions: 9 | contents: write # needed to write releases 10 | 11 | jobs: 12 | goreleaser: 13 | runs-on: ubuntu-22.04 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v4 17 | with: 18 | fetch-depth: 0 19 | 20 | - name: Setup Go 21 | uses: actions/setup-go@v1 22 | with: 23 | go-version: 1.21 24 | 25 | - name: Publish release 26 | uses: goreleaser/goreleaser-action@v5 27 | with: 28 | version: latest 29 | args: release --clean --timeout 60m 30 | env: 31 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 32 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/leodido/rn2md 2 | 3 | go 1.21 4 | 5 | require ( 6 | github.com/Masterminds/semver/v3 v3.2.1 7 | github.com/creasty/defaults v1.7.0 8 | github.com/go-playground/locales v0.14.1 9 | github.com/go-playground/universal-translator v0.18.1 10 | github.com/go-playground/validator/v10 v10.16.0 11 | github.com/gofri/go-github-ratelimit v1.0.7 12 | github.com/google/go-github/v28 v28.1.1 13 | github.com/olekukonko/tablewriter v0.0.5 14 | github.com/sirupsen/logrus v1.9.3 15 | github.com/spf13/cobra v1.8.0 16 | golang.org/x/oauth2 v0.15.0 17 | ) 18 | 19 | require ( 20 | github.com/gabriel-vasile/mimetype v1.4.3 // indirect 21 | github.com/golang/protobuf v1.5.3 // indirect 22 | github.com/google/go-querystring v1.1.0 // indirect 23 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 24 | github.com/leodido/go-urn v1.2.4 // indirect 25 | github.com/mattn/go-runewidth v0.0.15 // indirect 26 | github.com/rivo/uniseg v0.4.4 // indirect 27 | github.com/spf13/pflag v1.0.5 // indirect 28 | golang.org/x/crypto v0.17.0 // indirect 29 | golang.org/x/net v0.19.0 // indirect 30 | golang.org/x/sys v0.16.0 // indirect 31 | golang.org/x/text v0.14.0 // indirect 32 | google.golang.org/appengine v1.6.8 // indirect 33 | google.golang.org/protobuf v1.32.0 // indirect 34 | ) 35 | -------------------------------------------------------------------------------- /validate/validate.go: -------------------------------------------------------------------------------- 1 | package validate 2 | 3 | import ( 4 | "reflect" 5 | "strings" 6 | 7 | "github.com/go-playground/locales/en" 8 | ut "github.com/go-playground/universal-translator" 9 | "github.com/go-playground/validator/v10" 10 | en_translations "github.com/go-playground/validator/v10/translations/en" 11 | ) 12 | 13 | // V is the validator single instance. 14 | // 15 | // It is a singleton so to cache the structs info. 16 | var V *validator.Validate 17 | 18 | // T is the universal translator for validatiors. 19 | var T ut.Translator 20 | 21 | func init() { 22 | V = validator.New() 23 | 24 | // Register a function to get the field name from "name" tags. 25 | V.RegisterTagNameFunc(func(fld reflect.StructField) string { 26 | name := strings.SplitN(fld.Tag.Get("name"), ",", 2)[0] 27 | if name == "-" { 28 | return "" 29 | } 30 | return name 31 | }) 32 | 33 | V.RegisterValidation("semver", isSemVer) 34 | 35 | eng := en.New() 36 | uni := ut.New(eng, eng) 37 | T, _ = uni.GetTranslator("en") 38 | en_translations.RegisterDefaultTranslations(V, T) 39 | 40 | V.RegisterTranslation( 41 | "semver", 42 | T, 43 | func(ut ut.Translator) error { 44 | return ut.Add("semver", "{0} must be a semver-ish string", true) 45 | }, 46 | func(ut ut.Translator, fe validator.FieldError) string { 47 | t, _ := ut.T(fe.Tag(), fe.Field()) 48 | 49 | return t 50 | }, 51 | ) 52 | } 53 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: 'rn2md' 2 | description: 'Generate release notes and stats from release-note blocks found into your project pull requests.' 3 | 4 | outputs: 5 | path: 6 | description: Path for the output file 7 | value: ${{ steps.generate.outputs.path }} 8 | 9 | inputs: 10 | token: 11 | description: A GitHub personal API token to perform authenticated requests 12 | required: false 13 | default: ${{ github.token }} 14 | milestone: 15 | description: The milestone you want to filter by the pull requests 16 | required: true 17 | repo: 18 | description: The github repository name 19 | required: false 20 | default: ${{ github.repository }} 21 | branch: 22 | description: The target branch you want to filter by the pull requests 23 | required: false 24 | default: ${{ github.event.repository.default_branch }} 25 | output: 26 | description: Target file to be generated, relative to github workspace 27 | required: false 28 | default: release_body.md 29 | 30 | runs: 31 | using: "composite" 32 | steps: 33 | - uses: actions/setup-go@v4 34 | with: 35 | go-version: 1.21 36 | 37 | - id: generate 38 | shell: bash 39 | working-directory: ${{ github.action_path }} 40 | run: | 41 | go build . 42 | ./rn2md -b ${{ inputs.branch }} -r ${{ inputs.repo }} -m ${{ inputs.milestone }} -t ${{ inputs.token }} &> "${{ github.workspace }}/${{ inputs.output }}" 43 | echo "path=${{ github.workspace }}/${{ inputs.output }}" >> $GITHUB_OUTPUT 44 | -------------------------------------------------------------------------------- /cmd/opts.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/creasty/defaults" 8 | "github.com/go-playground/validator/v10" 9 | "github.com/leodido/rn2md/validate" 10 | logger "github.com/sirupsen/logrus" 11 | ) 12 | 13 | // Options represents the program options. 14 | type Options struct { 15 | Milestone string `validate:"required,semver" name:"milestone"` 16 | Branch string `default:"master" validate:"omitempty,ascii" name:"branch"` 17 | Repo string `validate:"required,ascii" name:"repository"` 18 | Token string `validate:"omitempty,len=40" name:"token"` 19 | } 20 | 21 | // NewOptions create a pointer to an Options instance. 22 | func NewOptions() *Options { 23 | o := &Options{} 24 | if err := defaults.Set(o); err != nil { 25 | logger.WithError(err).Fatal("error setting options") 26 | } 27 | return o 28 | } 29 | 30 | // Validate ensures Options are valid, otherwise returns all the occurred errors. 31 | func (o *Options) Validate() []error { 32 | if err := validate.V.Struct(o); err != nil { 33 | errors := err.(validator.ValidationErrors) 34 | errArr := []error{} 35 | for _, e := range errors { 36 | // Translate each error one at a time 37 | errArr = append(errArr, fmt.Errorf(e.Translate(validate.T))) 38 | } 39 | return errArr 40 | } 41 | return nil 42 | } 43 | 44 | func (o *Options) SplitRepoOrgName() (string, string, error) { 45 | names := strings.Split(o.Repo, "/") 46 | if len(names) != 2 { 47 | return "", "", fmt.Errorf("provided repo has wrong format, expected org/repo, actual: %s", o.Repo) 48 | } 49 | return names[0], names[1], nil 50 | } 51 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # release notes to markdown 2 | 3 | > Generate markdown for your changelogs from release-note blocks 4 | 5 | It expects release-note block rows to follow the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) format. 6 | 7 | Then, it extracts the `type` and creates different sections in the resulting markdown for different `type`. 8 | 9 | For example `new: ...` and `BREAKING CHANGE: ...` release-note rows populate the "Major Changes" section of the markdown. 10 | 11 | ## Usage 12 | 13 | ```bash 14 | rn2md -r falcosecurity/falco -m 0.21.0 15 | ``` 16 | 17 | ## Help 18 | 19 | ``` 20 | ./rn2md --help 21 | Little configurable CLI to generate the markdown for your changelogs from release-note blocks found into your project pull requests. 22 | 23 | Usage: 24 | rn2md [flags] 25 | 26 | Flags: 27 | -b, --branch string the target branch you want to filter by the pull requests (default "master") 28 | -h, --help help for rn2md 29 | -m, --milestone string the milestone you want to filter by the pull requests 30 | -r, --repo string the full github repository name (org/repo) 31 | -t, --token string a GitHub personal API token to perform authenticated requests 32 | ``` 33 | 34 | ## Using the github action in your repo 35 | 36 | To automatically generate release notes markdown for your project milestone, you must just add a step to your workflow. 37 | 38 | ```yaml 39 | - name: rn2md 40 | uses: leodido/rn2md@master 41 | with: 42 | # The milestone you want to filter by the pull requests. Required. 43 | milestone: 0.21.0 44 | # A github token needed for the github client API calls (listing repo PRs). Defaults to ${{ github.token }} 45 | token: mytoken 46 | # Full name for your repo. Defaults to ${{ github.repository }} 47 | repo: myorg/myrepo 48 | # Target branch to filter by the pull requests. Defaults to ${{ github.event.repository.default_branch }}. 49 | branch: main 50 | # Target file to be generated, relative to github workspace. Defaults to release_body.md 51 | output: out.md 52 | ``` 53 | 54 | --- 55 | 56 | [![Analytics](https://ga-beacon.appspot.com/UA-49657176-1/rn2md?flat)](https://github.com/igrigorik/ga-beacon) 57 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/leodido/rn2md/pkg/releasenotes" 5 | logger "github.com/sirupsen/logrus" 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | var opts = NewOptions() 10 | 11 | var program = &cobra.Command{ 12 | Use: "rn2md", 13 | Long: "Little configurable CLI to generate the markdown for your changelogs from release-note blocks found into your project pull requests.", 14 | Short: "Generate markdown for your changelogs from release-note blocks.", 15 | PersistentPreRun: func(c *cobra.Command, args []string) { 16 | if c.Name() != "help" { 17 | if errs := opts.Validate(); errs != nil { 18 | for _, err := range errs { 19 | logger.WithError(err).Error("error validating options") 20 | } 21 | logger.Fatal("exiting for validation errors") 22 | } 23 | } 24 | }, 25 | Run: func(c *cobra.Command, args []string) { 26 | client := releasenotes.NewClient(opts.Token) 27 | org, repo, err := opts.SplitRepoOrgName() 28 | if err != nil { 29 | logger.WithError(err).Fatal("error retrieving repo org and name") 30 | } 31 | notes, stats, err := client.Get(org, repo, opts.Branch, opts.Milestone) 32 | if err != nil { 33 | logger.WithError(err).Fatal("error retrieving PRs") 34 | } 35 | err = notes.Print(opts.Milestone) 36 | if err != nil { 37 | logger.WithError(err).Fatal("error printing out release notes") 38 | } 39 | 40 | // Print statistics 41 | err = stats.Print() 42 | if err != nil { 43 | logger.WithError(err).Fatal("error printing out release stats") 44 | } 45 | }, 46 | } 47 | 48 | func init() { 49 | cobra.OnInitialize(initConfig) 50 | 51 | // Setup flags before the command is initialized 52 | flags := program.PersistentFlags() 53 | flags.StringVarP(&opts.Milestone, "milestone", "m", opts.Milestone, "the milestone you want to filter by the pull requests") 54 | flags.StringVarP(&opts.Repo, "repo", "r", opts.Repo, "the full github repository name (org/repo)") 55 | flags.StringVarP(&opts.Branch, "branch", "b", opts.Branch, "the target branch you want to filter by the pull requests") 56 | flags.StringVarP(&opts.Token, "token", "t", opts.Token, "a GitHub personal API token to perform authenticated requests") 57 | } 58 | 59 | func initConfig() { 60 | // nop 61 | } 62 | 63 | // Run ... 64 | func Run() error { 65 | return program.Execute() 66 | } 67 | -------------------------------------------------------------------------------- /pkg/releasenotes/print.go: -------------------------------------------------------------------------------- 1 | package releasenotes 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "github.com/olekukonko/tablewriter" 7 | "os" 8 | "strconv" 9 | "strings" 10 | "text/template" 11 | "time" 12 | ) 13 | 14 | const templ = `## v{{ .Milestone }} 15 | 16 | 17 | Released on {{ .Day }} 18 | 19 | {{ if .BreakingNotes }} 20 | ### Breaking Changes :warning: 21 | 22 | {{ range .BreakingNotes }} 23 | * {{ .Description }} [[#{{ .Num }}]({{ .URI }})] - [{{ .Author }}]({{ .AuthorURL }}) 24 | {{ end }} 25 | {{ end }} 26 | 27 | {{ if .MajorNotes }} 28 | ### Major Changes 29 | 30 | {{ range .MajorNotes }} 31 | * {{ .Description }} [[#{{ .Num }}]({{ .URI }})] - [{{ .Author }}]({{ .AuthorURL }}) 32 | {{ end }} 33 | {{ end }} 34 | 35 | {{ if .MinorNotes }} 36 | ### Minor Changes 37 | 38 | {{ range .MinorNotes }} 39 | * {{ .Description }} [[#{{ .Num }}]({{ .URI }})] - [{{ .Author }}]({{ .AuthorURL }}) 40 | {{ end }} 41 | {{ end }} 42 | 43 | {{ if .FixNotes }} 44 | ### Bug Fixes 45 | 46 | {{ range .FixNotes }} 47 | * {{ .Description }} [[#{{ .Num }}]({{ .URI }})] - [{{ .Author }}]({{ .AuthorURL }}) 48 | {{ end }} 49 | {{ end }} 50 | 51 | {{ if .RuleNotes }} 52 | ### Rule Changes 53 | 54 | {{ range .RuleNotes }} 55 | * {{ .Description }} [[#{{ .Num }}]({{ .URI }})] - [{{ .Author }}]({{ .AuthorURL }}) 56 | {{ end }} 57 | {{ end }} 58 | 59 | {{ if .NoneNotes }} 60 | ### Non user-facing changes 61 | 62 | {{ range .NoneNotes }} 63 | * {{ .Description }} [[#{{ .Num }}]({{ .URI }})] - [{{ .Author }}]({{ .AuthorURL }}) 64 | {{ end }} 65 | {{ end }}` 66 | 67 | type templateData struct { 68 | Milestone string 69 | Day string 70 | BreakingNotes []ReleaseNote 71 | MajorNotes []ReleaseNote 72 | MinorNotes []ReleaseNote 73 | FixNotes []ReleaseNote 74 | RuleNotes []ReleaseNote 75 | NoneNotes []ReleaseNote 76 | } 77 | 78 | func (notes ReleaseNotes) Print(milestone string) error { 79 | var breaking []ReleaseNote 80 | var majors []ReleaseNote 81 | var minors []ReleaseNote 82 | var fixes []ReleaseNote 83 | var rules []ReleaseNote 84 | var none []ReleaseNote 85 | for _, n := range notes { 86 | switch n.Typology { 87 | case "BREAKING CHANGE": 88 | breaking = append(breaking, n) 89 | case "fix": 90 | fixes = append(fixes, n) 91 | case "rule": 92 | rules = append(rules, n) 93 | case "new", "feat": 94 | majors = append(majors, n) 95 | case "none": 96 | none = append(none, n) 97 | default: 98 | minors = append(minors, n) 99 | } 100 | } 101 | 102 | data := templateData{ 103 | Milestone: milestone, 104 | Day: time.Now().Format("2006-01-02"), 105 | MinorNotes: minors, 106 | BreakingNotes: breaking, 107 | MajorNotes: majors, 108 | FixNotes: fixes, 109 | RuleNotes: rules, 110 | NoneNotes: none, 111 | } 112 | 113 | t := template.New("changes") 114 | res, err := t.Parse(templ) 115 | if err != nil { 116 | return err 117 | } 118 | 119 | b := bytes.NewBuffer(nil) 120 | err = res.Execute(b, data) 121 | if err != nil { 122 | return err 123 | } 124 | 125 | result := strings.ReplaceAll(b.String(), "\n\n", "\n") 126 | fmt.Println(result) 127 | return nil 128 | } 129 | 130 | func (s *Statistics) Print() error { 131 | fmt.Println("### Statistics") 132 | fmt.Println("") 133 | 134 | table := tablewriter.NewWriter(os.Stdout) 135 | table.SetHeader([]string{"Merged PRs", "Number"}) 136 | table.SetBorders(tablewriter.Border{Left: true, Top: false, Right: true, Bottom: false}) 137 | table.SetCenterSeparator("|") 138 | 139 | table.Append([]string{"Not user-facing", strconv.FormatInt(s.nonFacing, 10)}) 140 | table.Append([]string{"Release note", strconv.FormatInt(s.total-s.nonFacing, 10)}) 141 | table.Append([]string{"Total", strconv.FormatInt(s.total, 10)}) 142 | 143 | table.Render() // Send output 144 | return nil 145 | } 146 | -------------------------------------------------------------------------------- /pkg/releasenotes/releasenotes.go: -------------------------------------------------------------------------------- 1 | package releasenotes 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "log" 8 | "regexp" 9 | "strings" 10 | "time" 11 | 12 | "github.com/gofri/go-github-ratelimit/github_ratelimit" 13 | "github.com/google/go-github/v28/github" 14 | "golang.org/x/oauth2" 15 | ) 16 | 17 | var ( 18 | releaseNoteRegexp = regexp.MustCompile("(?s)```release-note(.+?)```") 19 | typologyRegexp = regexp.MustCompile(`(?m)(.+?)(\((.+)\))?(!)?: ?(.*)`) 20 | ) 21 | 22 | const defaultGitHubBaseURI = "https://github.com" 23 | 24 | // ReleaseNote ... 25 | type ReleaseNote struct { 26 | Typology string 27 | Scope string 28 | Description string 29 | URI string 30 | Num int 31 | Author string 32 | AuthorURL string 33 | } 34 | 35 | type ReleaseNotes []ReleaseNote 36 | 37 | // Client ... 38 | type Client struct { 39 | c *github.Client 40 | } 41 | 42 | // NewClient ... 43 | func NewClient(token string) *Client { 44 | var client *github.Client 45 | // Eventually create an authenticated client 46 | if token != "" { 47 | ts := oauth2.StaticTokenSource( 48 | &oauth2.Token{AccessToken: token}, 49 | ) 50 | tc := oauth2.NewClient(context.Background(), ts) 51 | client = github.NewClient(tc) 52 | } else { 53 | // Force use a rate limited client. It will be slow but should overcome rate limiting issues. 54 | rateLimiter, err := github_ratelimit.NewRateLimitWaiterClient(nil) 55 | if err != nil { 56 | log.Fatal(err) 57 | } 58 | client = github.NewClient(rateLimiter) 59 | } 60 | 61 | return &Client{ 62 | c: client, 63 | } 64 | } 65 | 66 | // Get returns the list of release notes found for the given parameters. 67 | func (c *Client) Get(org, repo, branch, milestone string) (ReleaseNotes, *Statistics, error) { 68 | ctx := context.Background() 69 | listingOpts := &github.PullRequestListOptions{ 70 | State: "closed", 71 | Base: branch, 72 | Sort: "updated", 73 | Direction: "desc", 74 | ListOptions: github.ListOptions{ 75 | // https://docs.github.com/en/rest/pulls/pulls?apiVersion=2022-11-28#list-pull-requests 76 | // per_page integer 77 | // 78 | // The number of results per page (max 100). For more information, see "Using pagination in the REST API." 79 | // Default: 30 80 | PerPage: 100, 81 | }, 82 | } 83 | 84 | // https://docs.github.com/en/rest/pulls/pulls?apiVersion=2022-11-28#list-pull-requests 85 | // page integer 86 | // 87 | // The page number of the results to fetch. For more information, see "Using pagination in the REST API." 88 | // Default: 1 89 | page := 1 90 | prs := make([]*github.PullRequest, 0) 91 | for { 92 | listingOpts.Page = page 93 | pagedPrs, _, err := c.c.PullRequests.List(ctx, org, repo, listingOpts) 94 | var rateLimitErr *github.RateLimitError 95 | if errors.As(err, &rateLimitErr) { 96 | return nil, nil, fmt.Errorf("hit rate limiting") 97 | } 98 | if err != nil { 99 | return nil, nil, err 100 | } 101 | prs = append(prs, pagedPrs...) 102 | if len(pagedPrs) < listingOpts.PerPage { 103 | // We collected all prs! 104 | break 105 | } 106 | page++ 107 | } 108 | 109 | var releaseNotes []ReleaseNote 110 | s := &Statistics{ 111 | total: 0, 112 | nonFacing: 0, 113 | authors: make(map[string]int64), 114 | } 115 | for _, p := range prs { 116 | num := p.GetNumber() 117 | if p.GetMergedAt().Equal(time.Time{}) { 118 | continue 119 | } 120 | if p.GetMilestone().GetTitle() != milestone { 121 | continue 122 | } 123 | s.total++ 124 | s.authors[p.GetUser().GetLogin()] = s.authors[p.GetUser().GetLogin()] + 1 125 | 126 | res := releaseNoteRegexp.FindStringSubmatch(p.GetBody()) 127 | if len(res) < 1 { 128 | continue 129 | } 130 | note := strings.TrimSpace(res[1]) 131 | if strings.EqualFold(note, "NONE") { 132 | s.nonFacing++ 133 | rn := ReleaseNote{ 134 | Typology: "none", 135 | Scope: "", 136 | Description: p.GetTitle(), 137 | URI: fmt.Sprintf("%s/%s/%s/pull/%d", defaultGitHubBaseURI, org, repo, num), 138 | Num: num, 139 | Author: fmt.Sprintf("@%s", p.GetUser().GetLogin()), 140 | AuthorURL: p.GetUser().GetHTMLURL(), 141 | } 142 | releaseNotes = append(releaseNotes, rn) 143 | continue 144 | } 145 | 146 | notes := strings.Split(note, "\n") 147 | for _, n := range notes { 148 | n = strings.Trim(n, "\r") 149 | matches := typologyRegexp.FindStringSubmatch(n) 150 | if len(matches) < 6 { 151 | return nil, nil, fmt.Errorf("error extracting type from release note, pr: %d", num) 152 | } 153 | 154 | rn := ReleaseNote{ 155 | Typology: matches[1], 156 | Scope: matches[3], 157 | Description: n, 158 | URI: fmt.Sprintf("%s/%s/%s/pull/%d", defaultGitHubBaseURI, org, repo, num), 159 | Num: num, 160 | Author: fmt.Sprintf("@%s", p.GetUser().GetLogin()), 161 | AuthorURL: p.GetUser().GetHTMLURL(), 162 | } 163 | if matches[4] == "!" { 164 | rn.Typology = "BREAKING CHANGE" 165 | } 166 | releaseNotes = append(releaseNotes, rn) 167 | } 168 | } 169 | 170 | return releaseNotes, s, nil 171 | } 172 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0= 2 | github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= 3 | github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 4 | github.com/creasty/defaults v1.7.0 h1:eNdqZvc5B509z18lD8yc212CAqJNvfT1Jq6L8WowdBA= 5 | github.com/creasty/defaults v1.7.0/go.mod h1:iGzKe6pbEHnpMPtfDXZEr0NVxWnPTjb1bbDy08fPzYM= 6 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 7 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 8 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 9 | github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= 10 | github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= 11 | github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= 12 | github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= 13 | github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= 14 | github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= 15 | github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= 16 | github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= 17 | github.com/go-playground/validator/v10 v10.16.0 h1:x+plE831WK4vaKHO/jpgUGsvLKIqRRkz6M78GuJAfGE= 18 | github.com/go-playground/validator/v10 v10.16.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= 19 | github.com/gofri/go-github-ratelimit v1.0.7 h1:buFMVFyr/5f3/cYiYeIZ4g2VHRY3+NrINMzaoxOeUU0= 20 | github.com/gofri/go-github-ratelimit v1.0.7/go.mod h1:OnCi5gV+hAG/LMR7llGhU7yHt44se9sYgKPnafoL7RY= 21 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 22 | github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= 23 | github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 24 | github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= 25 | github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 26 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 27 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 28 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 29 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 30 | github.com/google/go-github/v28 v28.1.1 h1:kORf5ekX5qwXO2mGzXXOjMe/g6ap8ahVe0sBEulhSxo= 31 | github.com/google/go-github/v28 v28.1.1/go.mod h1:bsqJWQX05omyWVmc00nEUql9mhQyv38lDZ8kPZcQVoM= 32 | github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= 33 | github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 34 | github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 35 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 36 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 37 | github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= 38 | github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= 39 | github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= 40 | github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= 41 | github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 42 | github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= 43 | github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= 44 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 45 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 46 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 47 | github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= 48 | github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 49 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 50 | github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 51 | github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 52 | github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= 53 | github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= 54 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 55 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 56 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 57 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 58 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 59 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 60 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 61 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 62 | github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= 63 | github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 64 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 65 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 66 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 67 | golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= 68 | golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= 69 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 70 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 71 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 72 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 73 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 74 | golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c= 75 | golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= 76 | golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 77 | golang.org/x/oauth2 v0.15.0 h1:s8pnnxNVzjWyrvYdFUQq5llS1PX2zhPXmccZv99h7uQ= 78 | golang.org/x/oauth2 v0.15.0/go.mod h1:q48ptWNTY5XWf+JNten23lcvHpLJ0ZSxF5ttTHKVCAM= 79 | golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 80 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 81 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 82 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 83 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 84 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 85 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 86 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 87 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 88 | golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= 89 | golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 90 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 91 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 92 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 93 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 94 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 95 | golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= 96 | golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= 97 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 98 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 99 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 100 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 101 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 102 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 103 | google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= 104 | google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= 105 | google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= 106 | google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= 107 | google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= 108 | google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I= 109 | google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= 110 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 111 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 112 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 113 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 114 | --------------------------------------------------------------------------------