├── .gitignore ├── .goreleaser.yml ├── ghrn └── ghrn.go ├── license ├── main.go └── readme.md /.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | builds: 2 | - binary: github-release-notes 3 | goos: 4 | - darwin 5 | - linux 6 | - windows 7 | goarch: 8 | - amd64 9 | env: 10 | - CGO_ENABLED=0 11 | 12 | dist: build/release 13 | 14 | git: 15 | short_hash: true 16 | 17 | release: 18 | draft: true 19 | 20 | env_files: 21 | github_token: .github_token 22 | 23 | archive: 24 | name_template: "{{.ProjectName}}-{{.Os}}-{{.Arch}}-{{.Version}}" 25 | -------------------------------------------------------------------------------- /ghrn/ghrn.go: -------------------------------------------------------------------------------- 1 | package ghrn 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "strings" 9 | 10 | "github.com/google/go-github/github" 11 | "golang.org/x/oauth2" 12 | ) 13 | 14 | // Config describes configuration for BuildReleaseNotes. 15 | type Config struct { 16 | // Org is the name of the GitHub organization. Required. 17 | Org string 18 | // Repo is the name of the GitHub repository. Required. 19 | Repo string 20 | 21 | // GitHubToken is a GitHub API access token. 22 | GitHubToken string 23 | 24 | // StopAt is the number of the Pull Request to stop at. 25 | // Useful for building the notes of PRs since the last release, for example. 26 | StopAt int 27 | // IncludeCommits will include commmits messages for each PR. 28 | IncludeCommits bool 29 | // SinceLatestRelease will only include PRs and commits merged since the latest release tag. 30 | SinceLatestRelease bool 31 | // IncludeAuthor will prefix the message with an author of the PR 32 | IncludeAuthor bool 33 | } 34 | 35 | // BuildReleaseNotes lists GitHub Pull Requests and writes formatted release notes 36 | // to the given writer. 37 | func BuildReleaseNotes(ctx context.Context, w io.Writer, conf Config) error { 38 | 39 | if conf.Org == "" { 40 | return fmt.Errorf("Config.Org is required") 41 | } 42 | if conf.Repo == "" { 43 | return fmt.Errorf("Config.Repo is required") 44 | } 45 | 46 | var httpClient *http.Client 47 | if conf.GitHubToken != "" { 48 | ts := oauth2.StaticTokenSource( 49 | &oauth2.Token{AccessToken: conf.GitHubToken}, 50 | ) 51 | httpClient = oauth2.NewClient(ctx, ts) 52 | } 53 | cl := github.NewClient(httpClient) 54 | 55 | opt := &github.PullRequestListOptions{ 56 | ListOptions: github.ListOptions{PerPage: 100}, 57 | State: "closed", 58 | } 59 | 60 | repo, _, err := cl.Repositories.Get(ctx, conf.Org, conf.Repo) 61 | if err != nil { 62 | return fmt.Errorf("get repository: %+v", err) 63 | } 64 | var commitsNotMerged []string = nil 65 | // Iterate over all PRs 66 | for { 67 | prs, resp, err := cl.PullRequests.List(ctx, conf.Org, conf.Repo, opt) 68 | if err != nil { 69 | return fmt.Errorf("listing PRs: %s", err) 70 | } 71 | 72 | // Iterate over PRs in this page. 73 | for _, pr := range prs { 74 | if *pr.Number == conf.StopAt { 75 | return nil 76 | } 77 | if pr.MergedAt == nil { 78 | continue 79 | } 80 | 81 | commits, err := commitsAll(ctx, cl, conf.Org, conf.Repo, pr.GetNumber()) 82 | if err != nil { 83 | return fmt.Errorf("listing PR commits: %s", err) 84 | } 85 | 86 | if conf.SinceLatestRelease { 87 | if pr.GetBase().GetRef() != repo.GetDefaultBranch() { 88 | // Skip when PR base branch isn't a default branch 89 | continue 90 | } 91 | 92 | if commitsNotMerged == nil { 93 | commitsNotMerged, err = newCommits(ctx, cl, conf.Org, conf.Repo) 94 | if err != nil { 95 | return fmt.Errorf("listing new commits: %+v", err) 96 | } 97 | } 98 | if !any(commitHashes(commits), commitsNotMerged) { 99 | // Stop when a PR doesn't contain any commits from since the latest release. 100 | return nil 101 | } 102 | } 103 | 104 | if conf.IncludeAuthor { 105 | fmt.Fprintf(w, "- PR #%d - @%s - %s\n", pr.GetNumber(), *pr.GetUser().Login, pr.GetTitle()) 106 | } else { 107 | fmt.Fprintf(w, "- PR #%d %s\n", pr.GetNumber(), pr.GetTitle()) 108 | } 109 | 110 | if conf.IncludeCommits { 111 | // Iterate over all commits in this PR. 112 | for _, commit := range commits { 113 | sha := *commit.SHA 114 | msg := *commit.Commit.Message 115 | 116 | // Strip multiple lines (i.e. only take first line) 117 | if i := strings.Index(msg, "\n"); i != -1 { 118 | msg = msg[:i] 119 | } 120 | // Trim long lines 121 | if len(msg) > 90 { 122 | msg = msg[:90] + "..." 123 | } 124 | msg = strings.TrimSpace(msg) 125 | 126 | fmt.Fprintf(w, " - %s %s\n", sha, msg) 127 | } 128 | fmt.Fprintln(w) 129 | } 130 | } 131 | 132 | if resp.NextPage == 0 { 133 | break 134 | } 135 | opt.Page = resp.NextPage 136 | } 137 | return nil 138 | } 139 | 140 | func contains(a []string, e string) bool { 141 | for _, v := range a { 142 | if e == v { 143 | return true 144 | } 145 | } 146 | return false 147 | } 148 | 149 | func any(a []string, b []string) bool { 150 | for _, c := range a { 151 | if contains(b, c) { 152 | return true 153 | } 154 | } 155 | return false 156 | } 157 | 158 | func commitsAll(ctx context.Context, cl *github.Client, owner string, repo string, num int) ([]github.RepositoryCommit, error) { 159 | var list []github.RepositoryCommit 160 | commitOpt := &github.ListOptions{PerPage: 100} 161 | for { 162 | commits, resp, err := cl.PullRequests.ListCommits(ctx, owner, repo, num, commitOpt) 163 | if err != nil { 164 | return nil, fmt.Errorf("listing PR commits: %s", err) 165 | } 166 | 167 | for _, commit := range commits { 168 | list = append(list, *commit) 169 | } 170 | 171 | if resp.NextPage == 0 { 172 | break 173 | } 174 | commitOpt.Page = resp.NextPage 175 | } 176 | return list, nil 177 | } 178 | 179 | func commitHashes(commits []github.RepositoryCommit) []string { 180 | var newCommits []string 181 | for _, commit := range commits { 182 | newCommits = append(newCommits, commit.GetCommit().GetTree().GetSHA()) 183 | } 184 | return newCommits 185 | } 186 | 187 | func newCommits(ctx context.Context, cl *github.Client, owner string, repo string) ([]string, error) { 188 | repository, _, err := cl.Repositories.Get(ctx, owner, repo) 189 | if err != nil { 190 | return nil, fmt.Errorf("get repository: %+v", err) 191 | } 192 | 193 | rls, _, err := cl.Repositories.GetLatestRelease(ctx, owner, repo) 194 | if err != nil { 195 | return nil, fmt.Errorf("get latest release: %+v", err) 196 | } 197 | 198 | comp, _, err := cl.Repositories.CompareCommits(ctx, owner, repo, rls.GetTagName(), repository.GetDefaultBranch()) 199 | if err != nil { 200 | return nil, fmt.Errorf("compare commitse: %s..%s %+v", rls.GetTagName(), repository.GetDefaultBranch(), err) 201 | } 202 | 203 | return commitHashes(comp.Commits), nil 204 | } 205 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Alex Buchanan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "fmt" 7 | "os" 8 | 9 | "github.com/buchanae/github-release-notes/ghrn" 10 | ) 11 | 12 | func main() { 13 | conf := ghrn.Config{} 14 | 15 | flag.StringVar(&conf.Org, "org", conf.Org, "Organization. (Required)") 16 | flag.StringVar(&conf.Repo, "repo", conf.Repo, "Repo. (Required)") 17 | flag.IntVar(&conf.StopAt, "stop-at", conf.StopAt, "PR number to stop at") 18 | flag.BoolVar(&conf.IncludeCommits, "include-commits", conf.IncludeCommits, "Include commit messages") 19 | flag.BoolVar(&conf.SinceLatestRelease, "since-latest-release", conf.SinceLatestRelease, "Stop at latest release's commit") 20 | flag.BoolVar(&conf.IncludeAuthor, "include-author", conf.IncludeAuthor, "Include author of PR in message") 21 | flag.StringVar(&conf.GitHubToken, "github-token", "", "Github Token. (Defaults to env GITHUB_TOKEN)") 22 | flag.Parse() 23 | 24 | if conf.Org == "" { 25 | flag.Usage() 26 | fmt.Fprintln(os.Stderr, "\nError: -org is required.") 27 | os.Exit(1) 28 | } 29 | if conf.Repo == "" { 30 | flag.Usage() 31 | fmt.Fprintln(os.Stderr, "\nError: -repo is required.") 32 | os.Exit(1) 33 | } 34 | 35 | if conf.GitHubToken == "" { 36 | conf.GitHubToken = os.Getenv("GITHUB_TOKEN") 37 | } 38 | 39 | ctx := context.Background() 40 | err := ghrn.BuildReleaseNotes(ctx, os.Stdout, conf) 41 | if err != nil { 42 | fmt.Fprintln(os.Stderr, "Error: "+err.Error()) 43 | os.Exit(1) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | `github-release-notes` is a basic utility for generating release notes content from GitHub Pull Request history. 2 | 3 | ## Usage 4 | 5 | Download a binary from the [releases](https://github.com/buchanae/github-release-notes/releases) page, or run `go get github.com/buchanae/github-release-notes`. 6 | 7 | Run: 8 | ``` 9 | github-release-notes -org ohsu-comp-bio -repo funnel 10 | - PR #519 webdash: fixed elapsedTime calculation 11 | - PR #516 storage/swift: wrap errors with useful context 12 | - PR #515 Moving code 13 | - PR #514 build: fix release notes command 14 | - PR #513 Webdash upgrades 15 | - PR #512 worker/docker: log container metadata 16 | - PR #511 Unexport 17 | - PR #510 build: goreleaser, 0.6.0, github release notes gen 18 | ... 19 | ``` 20 | 21 | You can stop generating notes at a specific PR: 22 | ``` 23 | github-release-notes -org ohsu-comp-bio -repo funnel -stop-at 513 24 | - PR #519 webdash: fixed elapsedTime calculation 25 | - PR #516 storage/swift: wrap errors with useful context 26 | - PR #515 Moving code 27 | - PR #514 build: fix release notes command 28 | ``` 29 | 30 | You can generate notes for only PRs merged since the latest release: 31 | ``` 32 | github-release-notes -org ohsu-comp-bio -repo funnel -since-latest-release 33 | - PR #594 cmd/worker: run task from file 34 | - PR #593 storage/ftp: add FTP support 35 | ``` 36 | 37 | You can include the git commit messages for each PR: 38 | ``` 39 | github-release-notes -org ohsu-comp-bio -repo funnel -include-commits 40 | - PR #519 webdash: fixed elapsedTime calculation 41 | - 7675a5a5d577340b47e4dbdc5b83338c35a26392 webdash: fixed elapsedTime calculation 42 | 43 | - PR #516 storage/swift: wrap errors with useful context 44 | - 53b583c71da5e06c7dddd26e480f9099d6e8e60d storage/swift: wrap errors with useful context 45 | ``` 46 | 47 | You can use an [API access token][tok] by setting the `GITHUB_TOKEN` environment variable: 48 | ``` 49 | export GITHUB_TOKEN=1234... 50 | github-release-notes -org ohsu-comp-bio -repo funnel 51 | ``` 52 | 53 | [tok]: https://help.github.com/articles/creating-a-personal-access-token-for-the-command-line/ 54 | --------------------------------------------------------------------------------