├── .dockerignore ├── .github ├── dependabot.yml └── workflows │ ├── codeql.yml │ └── push-build-test-on-push.yml ├── .gitignore ├── .travis.yml ├── Dockerfile ├── History.markdown ├── LICENSE ├── Makefile ├── README.md ├── editor.go ├── editor_test.go ├── git.go ├── git_test.go ├── github.go ├── github_test.go ├── go.mod ├── go.sum ├── main.go ├── script ├── bootstrap ├── build ├── cibuild ├── coverage ├── release └── test ├── shell.go └── shell_test.go /.dockerignore: -------------------------------------------------------------------------------- 1 | merge-pr 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: gomod 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "11:00" 8 | open-pull-requests-limit: 99 9 | reviewers: 10 | - parkr 11 | - package-ecosystem: docker 12 | directory: "/" 13 | schedule: 14 | interval: daily 15 | time: "11:00" 16 | open-pull-requests-limit: 99 17 | reviewers: 18 | - parkr 19 | - package-ecosystem: github-actions 20 | directory: "/" 21 | schedule: 22 | interval: daily 23 | time: "11:00" 24 | open-pull-requests-limit: 99 25 | reviewers: 26 | - parkr 27 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | schedule: 9 | - cron: "38 6 * * 2" 10 | 11 | jobs: 12 | analyze: 13 | name: Analyze 14 | runs-on: ubuntu-latest 15 | permissions: 16 | actions: read 17 | contents: read 18 | security-events: write 19 | 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | language: [ go ] 24 | 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@v4 28 | 29 | - name: Initialize CodeQL 30 | uses: github/codeql-action/init@v3 31 | with: 32 | languages: ${{ matrix.language }} 33 | queries: +security-and-quality 34 | 35 | - name: Autobuild 36 | uses: github/codeql-action/autobuild@v3 37 | 38 | - name: Perform CodeQL Analysis 39 | uses: github/codeql-action/analyze@v3 40 | with: 41 | category: "/language:${{ matrix.language }}" 42 | -------------------------------------------------------------------------------- /.github/workflows/push-build-test-on-push.yml: -------------------------------------------------------------------------------- 1 | on: push 2 | name: Build & test 3 | jobs: 4 | buildAndTest: 5 | name: Build & Test 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v4 9 | - name: Build & Test 10 | uses: parkr/actions/docker-make@main 11 | with: 12 | args: docker-test -e REV=${{ github.sha }} 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /coverage.out 2 | /merge-pr 3 | /pkg 4 | /src 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | go: 3 | - 1.11 4 | sudo: false 5 | before_install: 'echo -e "machine api.github.com\n\tlogin travis\n\tpassword helloiamatoken" > $HOME/.netrc' 6 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:alpine AS builder 2 | ARG REV 3 | WORKDIR /usr/src/myapp 4 | COPY . . 5 | RUN set -ex \ 6 | && apk add --no-cache git \ 7 | && CI=1 CGO_ENABLED=0 time go test -v ./... \ 8 | && CGO_ENABLED=0 go build -ldflags "-s -X main.revision=${REV}" 9 | 10 | FROM golang:alpine 11 | RUN apk add --no-cache git 12 | COPY --from=builder /usr/src/myapp/merge-pr /bin/merge-pr 13 | ENTRYPOINT [ "/bin/merge-pr" ] 14 | -------------------------------------------------------------------------------- /History.markdown: -------------------------------------------------------------------------------- 1 | ## HEAD 2 | 3 | * git: don't requre a `.git` suffix for the git remote URL (#26) 4 | 5 | ## 1.1.2 / 2015-05-12 6 | 7 | * git: commit computed history file instead of hard-coded history.markdown (#24) 8 | 9 | ## 1.1.1 / 2015-05-07 10 | 11 | * git: put `[ci skip]` in the commit msg body instead of the summary (#22) 12 | 13 | ## 1.1.0 / 2015-03-11 14 | 15 | * git: if 'git pull' fails, do not continue (#21) 16 | * fix formatting for version printing (#20) 17 | * git: only merge if the current branch is master, staging, or dev (#19) 18 | * github: delete the branch properly (#18) 19 | * Replace go-octokit with go-github. (#17) 20 | 21 | ## 1.0.0 / 2015-02-19 22 | 23 | * git: use 'git config' to get origin URL (#12) 24 | * Add more printed info with the `-v` flag (#9) 25 | * Shell out to the OS with a common interface (#8) 26 | * Push once the merge commit is added. (#7) 27 | * Make the fetching of the history file more flexible. (#6) 28 | * When running the commit command, just use Stdout. (#5) 29 | * Delete the branch once it's been merged. (#4) 30 | * Configure to work with Travis (#3) 31 | * Birthday! (#1) 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Parker Moore 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | RELEASE=$(shell git rev-parse HEAD) 2 | REV=$(shell git rev-parse HEAD) 3 | 4 | all: build test 5 | 6 | deps: 7 | go get \ 8 | github.com/bgentry/go-netrc/netrc \ 9 | github.com/google/go-github/v50/github \ 10 | golang.org/x/oauth2 \ 11 | 12 | testdeps: 13 | go get \ 14 | github.com/stretchr/testify/assert \ 15 | golang.org/x/tools/cmd/cover 16 | 17 | build: deps 18 | go build \ 19 | -ldflags "-X main.release=$(RELEASE)" \ 20 | -o merge-pr 21 | 22 | test: deps testdeps 23 | go fmt 24 | go test 25 | 26 | docker-build: 27 | docker build --build-arg REV=$(REV) -t parkr/merge-pr . 28 | 29 | docker-test: docker-build 30 | docker run --rm parkr/merge-pr -V 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # merge-pr 2 | 3 | Merge your GitHub pull requests from the command line. 4 | 5 | [![Build Status](https://travis-ci.org/parkr/merge-pr.svg?branch=main)](https://travis-ci.org/parkr/merge-pr) 6 | 7 | ## Motivation 8 | 9 | Merging pull requests in the browser is nice, sure, but you lose a lot of 10 | clarity into what changed & when at a higher level. When did you add that 11 | feature? Oh, you're releasing a new patch? What changes did you make? No, I 12 | won't read through your commit history. 13 | 14 | This tool aims to make it easy to merge PR's and add a line to the 15 | CHANGELOG (`History.markdown` by default). All with one command. 16 | 17 | Here's more on [why you should keep a changelog.](http://keepachangelog.com/) 18 | 19 | ## Installation 20 | 21 | You need [Go](https://golang.org) and you need your `$GOPATH` set & 22 | `$GOPATH/bin` in your `$PATH`. Then: 23 | 24 | ```bash 25 | $ go get github.com/parkr/merge-pr 26 | ``` 27 | 28 | Throw your credentials in `$HOME/.netrc`, like this: 29 | 30 | ```text 31 | machine api.github.com 32 | login yourusername 33 | password mypersonalaccesstokenforgithub 34 | ``` 35 | 36 | Grab a personal access token on the [GitHub Applications Setting 37 | page](https://github.com/settings/applications). 38 | 39 | ## Usage 40 | 41 | ```bash 42 | $ cd my-project 43 | $ merge-pr 7 44 | ``` 45 | 46 | It uses the origin remote to discern which repo to make the API requests 47 | against, so ensure your `origin` is pointed to the repository that you 48 | want to merge the pull request into. 49 | 50 | This will go to GitHub, merge the PR, delete the branch if it's on the same 51 | repo, will pull down those changes, open up your editor (`$EDITOR`), then 52 | commit that change. 53 | 54 | ## Contributing 55 | 56 | To get setup, clone the repo and run `script/bootstrap`. Make your edits. 57 | Add tests where you can. Run tests with `script/test`, use `script/cibuild` 58 | to run the tests and build the binary if the tests are successful. 59 | 60 | Once you're happy with your change, submit a PR. If I like it, I'll use 61 | this tool to merge it! 62 | 63 | ## Versioning 64 | 65 | We adhere to SemVer where applicable. To see the version of your copy of 66 | `merge-pr`, run `merge-pr -V`. 67 | 68 | To release a new version, change the version in `main.go`, and run 69 | `script/release`. 70 | 71 | ## Credits / License 72 | 73 | MIT License, copyright Parker Moore. Details in the `LICENSE` file. 74 | -------------------------------------------------------------------------------- /editor.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "os" 7 | "regexp" 8 | ) 9 | 10 | var historyFilenameRegexp = regexp.MustCompile("(?i:(History|Changelog).m(ar)?k?d(own)?)") 11 | 12 | func openEditor() { 13 | shellExec(os.Getenv("EDITOR"), historyFile()) 14 | } 15 | 16 | func historyFile() string { 17 | infos, err := ioutil.ReadDir(".") 18 | if err != nil { 19 | fmt.Println("Problem finding your history file.") 20 | os.Exit(1) 21 | } 22 | for _, info := range infos { 23 | if isHistoryFile(info.Name()) { 24 | return info.Name() 25 | } 26 | } 27 | return "History.markdown" 28 | } 29 | 30 | func isHistoryFile(filename string) bool { 31 | return historyFilenameRegexp.FindString(filename) != "" 32 | } 33 | -------------------------------------------------------------------------------- /editor_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestIsHistoryFile(t *testing.T) { 10 | assert.True(t, isHistoryFile("History.markdown")) 11 | assert.True(t, isHistoryFile("HISTORY.markdown")) 12 | assert.True(t, isHistoryFile("History.mkd")) 13 | assert.True(t, isHistoryFile("History.mkdn")) 14 | assert.True(t, isHistoryFile("History.md")) 15 | assert.True(t, isHistoryFile("History.MD")) 16 | assert.True(t, isHistoryFile("HISTORY.MD")) 17 | assert.True(t, isHistoryFile("History.MKDN")) 18 | assert.True(t, isHistoryFile("Changelog.mkdn")) 19 | assert.True(t, isHistoryFile("CHANGELOG.markdown")) 20 | assert.True(t, isHistoryFile("CHANGELOG.MD")) 21 | assert.True(t, isHistoryFile("Changelog.mkd")) 22 | } 23 | 24 | func TestFindHistoryFile(t *testing.T) { 25 | assert.Equal(t, "History.markdown", historyFile()) 26 | } 27 | -------------------------------------------------------------------------------- /git.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | ) 7 | 8 | var ( 9 | GitRemoteRegexp = regexp.MustCompile("(https|git)(@|://)github\\.com(:|/)([a-zA-Z0-9-_]+)/([a-zA-Z0-9-_]+)(?:\\.git)?") 10 | acceptableBranches = []string{"master", "main", "staging", "dev"} 11 | ) 12 | 13 | func contains(s []string, e string) bool { 14 | for _, a := range s { 15 | if a == e { 16 | return true 17 | } 18 | } 19 | return false 20 | } 21 | 22 | func currentBranch() string { 23 | return shellOutput("git", "rev-parse", "--abbrev-ref", "HEAD") 24 | } 25 | 26 | func isAcceptableCurrentBranch() error { 27 | currBranch := currentBranch() 28 | if !contains(acceptableBranches, currBranch) { 29 | return fmt.Errorf("Unacceptable local branch: %s", currBranch) 30 | } 31 | return nil 32 | } 33 | 34 | func fetchRepoOwnerAndName() (string, string) { 35 | return extractOwnerAndNameFromRemote(gitOriginRemote()) 36 | } 37 | 38 | func extractOwnerAndNameFromRemote(url string) (string, string) { 39 | matches := GitRemoteRegexp.FindStringSubmatch(url) 40 | if len(matches) < 2 { 41 | return "", "" 42 | } 43 | return matches[len(matches)-2], matches[len(matches)-1] 44 | } 45 | 46 | func gitOriginRemote() string { 47 | return shellOutput("git", "config", "remote.origin.url") 48 | } 49 | 50 | func gitPull() error { 51 | return shellExec("git", "pull", "--rebase") 52 | } 53 | 54 | func gitPush() { 55 | shellExec("git", "push") 56 | } 57 | 58 | func commitChangesToHistoryFile(pr string) { 59 | shellExec("git", "add", historyFile()) 60 | shellExec( 61 | "git", 62 | "commit", 63 | "-m", 64 | "Update history to reflect merge of #"+pr, 65 | "-m", 66 | "[ci skip]", 67 | ) 68 | } 69 | -------------------------------------------------------------------------------- /git_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | var ( 11 | gitRemoteSSH = "git@github.com:parkr/merge-pr.git" 12 | gitRemoteHTTPS = "https://github.com/parkr/merge-pr.git" 13 | gitRemoteGit = "git://github.com/parkr/merge-pr.git" 14 | ) 15 | 16 | func TestCurrentBranch(t *testing.T) { 17 | branch := currentBranch() 18 | assert.NotEmpty(t, branch) 19 | } 20 | 21 | func TestIsAcceptableCurrentBranch(t *testing.T) { 22 | branch := currentBranch() 23 | err := isAcceptableCurrentBranch() 24 | switch branch { 25 | case "main", "master", "staging", "dev": 26 | assert.NoError(t, err) 27 | default: 28 | assert.EqualError(t, err, fmt.Sprintf("Unacceptable local branch: %s", branch)) 29 | } 30 | } 31 | 32 | func TestOriginRemote(t *testing.T) { 33 | assert.Regexp(t, GitRemoteRegexp, gitRemoteGit) 34 | assert.Regexp(t, GitRemoteRegexp, gitRemoteSSH) 35 | 36 | origin := gitOriginRemote() 37 | assert.Regexp(t, GitRemoteRegexp, origin) 38 | } 39 | 40 | func TestExtractOwnerAndRepoWithSSHUrl(t *testing.T) { 41 | owner, repo := extractOwnerAndNameFromRemote(gitRemoteSSH) 42 | assert.Equal(t, "parkr", owner) 43 | assert.Equal(t, "merge-pr", repo) 44 | } 45 | 46 | func TestExtractOwnerAndRepoWithGitUrl(t *testing.T) { 47 | owner, repo := extractOwnerAndNameFromRemote(gitRemoteGit) 48 | assert.Equal(t, "parkr", owner) 49 | assert.Equal(t, "merge-pr", repo) 50 | } 51 | 52 | func TestExtractOwnerAndRepoWithHTTPSUrl(t *testing.T) { 53 | owner, repo := extractOwnerAndNameFromRemote(gitRemoteHTTPS) 54 | assert.Equal(t, "parkr", owner) 55 | assert.Equal(t, "merge-pr", repo) 56 | } 57 | 58 | func TestExtractOwnerAndRepoWithBadURL(t *testing.T) { 59 | owner, repo := extractOwnerAndNameFromRemote("git@github.com:L!!!!RS/mars.git") 60 | assert.Equal(t, "", owner) 61 | assert.Equal(t, "", repo) 62 | } 63 | 64 | func TestFetchRepoOwnerAndName(t *testing.T) { 65 | owner, repo := fetchRepoOwnerAndName() 66 | assert.Contains(t, owner, "parkr") 67 | assert.Contains(t, repo, "merge-pr") 68 | } 69 | -------------------------------------------------------------------------------- /github.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "log" 8 | "net/http" 9 | "os" 10 | "path/filepath" 11 | "strconv" 12 | 13 | "github.com/bgentry/go-netrc/netrc" 14 | "github.com/google/go-github/v50/github" 15 | "golang.org/x/oauth2" 16 | "gopkg.in/yaml.v2" 17 | ) 18 | 19 | var ( 20 | client *github.Client 21 | 22 | NotMergableError = errors.New("Not mergable") 23 | BranchNotFoundError = errors.New("Branch not found") 24 | NonDeletableBranchError = errors.New("Branch cannot be deleted") 25 | PullReqNotFoundError = errors.New("Pull request not found") 26 | ) 27 | 28 | func initializeGitHubClient() { 29 | client = github.NewClient(newClient()) 30 | } 31 | 32 | type tokenSource struct { 33 | token *oauth2.Token 34 | } 35 | 36 | func (t *tokenSource) Token() (*oauth2.Token, error) { 37 | return t.token, nil 38 | } 39 | 40 | func hubConfigPath() string { 41 | filename := filepath.Join(os.Getenv("HOME"), ".config", "hub") 42 | if _, err := os.Stat(filename); os.IsNotExist(err) { 43 | if verbose { 44 | fmt.Printf("no such file or directory: %s", filename) 45 | } 46 | return "" 47 | } 48 | return filename 49 | } 50 | 51 | func accessTokenFromHubConfig() string { 52 | f, err := os.Open(hubConfigPath()) 53 | if err != nil { 54 | return "" 55 | } 56 | 57 | config := struct { 58 | GitHub []struct { 59 | OauthToken string `yaml:"oauth_token"` 60 | } `yaml:"github.com"` 61 | }{} 62 | err = yaml.NewDecoder(f).Decode(&config) 63 | if err != nil { 64 | log.Printf("couldn't decode hub config: %+v", err) 65 | return "" 66 | } 67 | if len(config.GitHub) == 0 { 68 | return "" 69 | } 70 | return config.GitHub[0].OauthToken 71 | } 72 | 73 | func netrcPath() string { 74 | filename := filepath.Join(os.Getenv("HOME"), ".netrc") 75 | if _, err := os.Stat(filename); os.IsNotExist(err) { 76 | if verbose { 77 | fmt.Printf("no such file or directory: %s", filename) 78 | } 79 | return "" 80 | } 81 | return filename 82 | } 83 | 84 | func accessTokenFromNetrc() string { 85 | if netrcPath() == "" { 86 | return "" 87 | } 88 | 89 | machine, err := netrc.FindMachine(netrcPath(), "api.github.com") 90 | if err != nil { 91 | panic(err) 92 | } 93 | if machine == nil { 94 | return "" 95 | } 96 | return machine.Password 97 | } 98 | 99 | func newClient() *http.Client { 100 | if accessToken := accessTokenFromNetrc(); accessToken != "" { 101 | return oauth2.NewClient(oauth2.NoContext, &tokenSource{ 102 | &oauth2.Token{AccessToken: accessToken}, 103 | }) 104 | } 105 | if accessToken := accessTokenFromHubConfig(); accessToken != "" { 106 | return oauth2.NewClient(oauth2.NoContext, &tokenSource{ 107 | &oauth2.Token{AccessToken: accessToken}, 108 | }) 109 | } 110 | return http.DefaultClient 111 | } 112 | 113 | func stringToInt(number string) int { 114 | intVal, err := strconv.Atoi(number) 115 | if err != nil { 116 | panic(err) 117 | } 118 | return intVal 119 | } 120 | 121 | func getPullRequest(owner, repo, number string) (*github.PullRequest, error) { 122 | pr, res, prGetErr := client.PullRequests.Get(context.Background(), owner, repo, stringToInt(number)) 123 | if prGetErr != nil { 124 | switch res.StatusCode { 125 | case http.StatusNotFound: 126 | return nil, PullReqNotFoundError 127 | default: 128 | return nil, prGetErr 129 | } 130 | } 131 | return pr, prGetErr 132 | } 133 | 134 | func mergePullRequest(owner, repo, number string) error { 135 | if verbose { 136 | log.Printf("Attempting to merge PR #%s on %s/%s...\n", number, owner, repo) 137 | } 138 | 139 | commitMsg := fmt.Sprintf("Merge pull request %v", number) 140 | _, res, mergeErr := client.PullRequests.Merge( 141 | context.Background(), 142 | owner, 143 | repo, 144 | stringToInt(number), 145 | commitMsg, 146 | &github.PullRequestOptions{}, 147 | ) 148 | 149 | if mergeErr != nil { 150 | if verbose { 151 | fmt.Println("Received an error!", mergeErr) 152 | } 153 | // https://docs.github.com/en/rest/pulls/pulls?apiVersion=2022-11-28#merge-a-pull-request--status-codes 154 | switch res.StatusCode { 155 | case http.StatusMethodNotAllowed: 156 | return NotMergableError 157 | case http.StatusNotFound: 158 | return PullReqNotFoundError 159 | default: 160 | return mergeErr 161 | } 162 | } 163 | 164 | return nil 165 | } 166 | 167 | func deleteBranchForPullRequest(owner, repo, number string) error { 168 | pr, prGetErr := getPullRequest(owner, repo, number) 169 | if prGetErr != nil { 170 | return prGetErr 171 | } 172 | 173 | if *pr.Head.User.Login == owner && *pr.Head.Ref != "" { 174 | if verbose { 175 | log.Println("Deleting the branch.") 176 | } 177 | err := deleteBranch(owner, repo, *pr.Head.Ref) 178 | if err != nil { 179 | return err 180 | } 181 | } 182 | 183 | return nil 184 | } 185 | 186 | func deleteBranch(owner, repo, branch string) error { 187 | switch branch { 188 | case "main", "master", "gh-pages", "dev", "staging": 189 | return NonDeletableBranchError 190 | } 191 | 192 | ref := fmt.Sprintf("heads/%s", branch) 193 | 194 | res, deleteBranchErr := client.Git.DeleteRef(context.Background(), owner, repo, ref) 195 | 196 | if deleteBranchErr != nil { 197 | switch res.StatusCode { 198 | case 422: 199 | return BranchNotFoundError 200 | default: 201 | return deleteBranchErr 202 | } 203 | } 204 | 205 | return nil 206 | } 207 | -------------------------------------------------------------------------------- /github_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "net/url" 7 | "os" 8 | "testing" 9 | 10 | "github.com/google/go-github/v50/github" 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | var isCI = os.Getenv("CI") == "1" 15 | 16 | func initializeTestClient(handler http.Handler) *httptest.Server { 17 | server := httptest.NewServer(handler) 18 | u, _ := url.Parse(server.URL) 19 | u.Path = "/v3/" 20 | 21 | client = github.NewClient(newClient()) 22 | client.BaseURL = u 23 | 24 | return server 25 | } 26 | 27 | func TestMergePullRequest_WithIssue(t *testing.T) { 28 | mux := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 29 | assert.Equal(t, "/v3/repos/parkr/merge-pr/pulls/2/merge", r.URL.Path) 30 | assert.Equal(t, "PUT", r.Method) 31 | w.WriteHeader(http.StatusNotFound) 32 | }) 33 | server := initializeTestClient(mux) 34 | defer server.Close() 35 | 36 | err := mergePullRequest("parkr", "merge-pr", "2") 37 | 38 | assert.EqualError(t, err, "Pull request not found") 39 | } 40 | 41 | func TestMergePullRequest_WithAlreadyMergedPR(t *testing.T) { 42 | mux := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 43 | assert.Equal(t, "/v3/repos/parkr/merge-pr/pulls/1/merge", r.URL.Path) 44 | assert.Equal(t, "PUT", r.Method) 45 | w.WriteHeader(http.StatusMethodNotAllowed) 46 | }) 47 | server := initializeTestClient(mux) 48 | defer server.Close() 49 | 50 | err := mergePullRequest("parkr", "merge-pr", "1") 51 | 52 | assert.EqualError(t, err, "Not mergable") 53 | } 54 | 55 | func TestGetPullRequest(t *testing.T) { 56 | mux := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 57 | assert.Equal(t, "/v3/repos/parkr/merge-pr/pulls/1", r.URL.Path) 58 | assert.Equal(t, "GET", r.Method) 59 | w.Write([]byte(`{"head":{"ref":"do-it-all","user":{"login":"parkr"}}}`)) 60 | }) 61 | server := initializeTestClient(mux) 62 | defer server.Close() 63 | 64 | pr, err := getPullRequest("parkr", "merge-pr", "1") 65 | 66 | assert.NoError(t, err) 67 | assert.NotNil(t, pr) 68 | assert.Equal(t, "do-it-all", *pr.Head.Ref) 69 | assert.Equal(t, "parkr", *pr.Head.User.Login) 70 | } 71 | 72 | func TestDeleteBranchForPR_BranchNotFound(t *testing.T) { 73 | mux := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 74 | if r.URL.Path == "/v3/repos/parkr/merge-pr/pulls/1" { 75 | assert.Equal(t, "GET", r.Method) 76 | w.Write([]byte(`{"head":{"ref":"do-it-all","user":{"login":"parkr"}}}`)) 77 | return 78 | } 79 | if r.URL.Path == "/v3/repos/parkr/merge-pr/git/refs/heads/do-it-all" { 80 | assert.Equal(t, "DELETE", r.Method) 81 | w.WriteHeader(http.StatusUnprocessableEntity) 82 | return 83 | } 84 | assert.Equal(t, "not expected", r.URL.Path) 85 | assert.Equal(t, "not expected", r.Method) 86 | w.Write([]byte(`{}`)) 87 | }) 88 | server := initializeTestClient(mux) 89 | defer server.Close() 90 | 91 | err := deleteBranchForPullRequest("parkr", "merge-pr", "1") 92 | 93 | assert.EqualError(t, err, "Branch not found") 94 | } 95 | 96 | func TestDeleteBranchForPR_ForNonPR(t *testing.T) { 97 | mux := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 98 | if r.URL.Path == "/v3/repos/parkr/merge-pr/pulls/2" { 99 | assert.Equal(t, "GET", r.Method) 100 | w.WriteHeader(http.StatusNotFound) 101 | w.Write([]byte(`{"head":{"ref":"do-it-all","user":{"login":"parkr"}}}`)) 102 | return 103 | } 104 | assert.Equal(t, "expected", r.URL.Path) 105 | assert.Equal(t, "expected", r.Method) 106 | w.Write([]byte(`{}`)) 107 | }) 108 | server := initializeTestClient(mux) 109 | defer server.Close() 110 | 111 | err := deleteBranchForPullRequest("parkr", "merge-pr", "2") 112 | 113 | assert.EqualError(t, err, "Pull request not found") 114 | } 115 | 116 | func TestDeleteBranch(t *testing.T) { 117 | mux := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 118 | if r.URL.Path == "/v3/repos/parkr/merge-pr/git/refs/heads/do-it-all" { 119 | assert.Equal(t, "DELETE", r.Method) 120 | w.WriteHeader(http.StatusUnprocessableEntity) 121 | return 122 | } 123 | }) 124 | server := initializeTestClient(mux) 125 | defer server.Close() 126 | 127 | err := deleteBranch("parkr", "merge-pr", "do-it-all") 128 | 129 | assert.EqualError(t, err, "Branch not found") 130 | } 131 | 132 | func TestDeleteBranch_WithProtectedBranch(t *testing.T) { 133 | mux := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 134 | assert.Equal(t, "expected", r.URL.Path) 135 | assert.Equal(t, "expected", r.Method) 136 | w.Write([]byte(`{}`)) 137 | }) 138 | server := initializeTestClient(mux) 139 | defer server.Close() 140 | 141 | err := deleteBranch("parkr", "merge-pr", "gh-pages") 142 | 143 | assert.EqualError(t, err, "Branch cannot be deleted") 144 | } 145 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module byparker.com/go/merge-pr 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.24.0 6 | 7 | require ( 8 | github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d 9 | github.com/google/go-github/v50 v50.2.0 10 | github.com/stretchr/testify v1.10.0 11 | golang.org/x/oauth2 v0.30.0 12 | gopkg.in/yaml.v2 v2.4.0 13 | ) 14 | 15 | require ( 16 | github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 // indirect 17 | github.com/cloudflare/circl v1.1.0 // indirect 18 | github.com/davecgh/go-spew v1.1.1 // indirect 19 | github.com/google/go-querystring v1.1.0 // indirect 20 | github.com/pmezard/go-difflib v1.0.0 // indirect 21 | golang.org/x/crypto v0.21.0 // indirect 22 | golang.org/x/sys v0.18.0 // indirect 23 | gopkg.in/yaml.v3 v3.0.1 // indirect 24 | ) 25 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 h1:wPbRQzjjwFc0ih8puEVAOFGELsn1zoIIYdxvML7mDxA= 2 | github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8/go.mod h1:I0gYDMZ6Z5GRU7l58bNFSkPTFN6Yl12dsUlAZ8xy98g= 3 | github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d h1:xDfNPAt8lFiC1UJrqV3uuy861HCTo708pDMbjHHdCas= 4 | github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d/go.mod h1:6QX/PXZ00z/TKoufEY6K/a0k6AhaJrQKdFe6OfVXsa4= 5 | github.com/bwesterb/go-ristretto v1.2.0/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= 6 | github.com/cloudflare/circl v1.1.0 h1:bZgT/A+cikZnKIwn7xL2OBj012Bmvho/o6RpRvv3GKY= 7 | github.com/cloudflare/circl v1.1.0/go.mod h1:prBCrKB9DV4poKZY1l9zBXg2QJY7mvgRvtMxxK7fi4I= 8 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 9 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 10 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 11 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 12 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 13 | github.com/google/go-github/v50 v50.2.0 h1:j2FyongEHlO9nxXLc+LP3wuBSVU9mVxfpdYUexMpIfk= 14 | github.com/google/go-github/v50 v50.2.0/go.mod h1:VBY8FB6yPIjrtKhozXv4FQupxKLS6H4m6xFZlT43q8Q= 15 | github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 16 | github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 17 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 18 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 19 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 20 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 21 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 22 | golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= 23 | golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= 24 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 25 | golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= 26 | golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= 27 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 28 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 29 | golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 30 | golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= 31 | golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 32 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 33 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 34 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 35 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 36 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 37 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 38 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 39 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 40 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 41 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 42 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "log" 7 | "os" 8 | ) 9 | 10 | var ( 11 | verbose bool 12 | showVersion bool 13 | version = "1.2.0" 14 | revision = "dev" 15 | ) 16 | 17 | func fatalError(format string, args ...interface{}) { 18 | fmt.Printf(format+"\n", args...) 19 | os.Exit(1) 20 | } 21 | 22 | func main() { 23 | flag.BoolVar(&verbose, "v", false, "run verbosely") 24 | flag.BoolVar(&showVersion, "V", false, "print version and exit") 25 | flag.Parse() 26 | 27 | if showVersion { 28 | fmt.Printf("merge-pr %s (%s)\n", version, revision) 29 | os.Exit(0) 30 | } 31 | 32 | number := flag.Arg(0) 33 | if number == "" { 34 | fatalError("Specify a PR number without the #.") 35 | } 36 | 37 | if verbose { 38 | log.Println("Determining if your local branch is cool.") 39 | } 40 | err := isAcceptableCurrentBranch() 41 | if err != nil { 42 | fatalError(err.Error()) 43 | } 44 | 45 | initializeGitHubClient() 46 | 47 | if verbose { 48 | log.Println("Fetching owner & repo from your git remotes") 49 | } 50 | owner, repo := fetchRepoOwnerAndName() 51 | if owner == "" || repo == "" { 52 | fatalError("You don't have an 'origin' remote. Failing.") 53 | } 54 | 55 | if verbose { 56 | log.Println("Attempting to merge the PR.") 57 | } 58 | err = mergePullRequest(owner, repo, number) 59 | if err == nil { 60 | if verbose { 61 | log.Println("Deleting branch for PR.") 62 | } 63 | err = deleteBranchForPullRequest(owner, repo, number) 64 | if err != nil { 65 | fmt.Println("Error deleting the branch:", err) 66 | } 67 | } else { 68 | if err == NotMergableError { 69 | fmt.Print("That PR can't be merged. Continue anyway? (y/n) ") 70 | var answer string 71 | fmt.Scanln(&answer) 72 | if answer != "y" { 73 | os.Exit(1) 74 | } 75 | } else { 76 | fatalError(err.Error()) 77 | } 78 | } 79 | 80 | if err := gitPull(); err != nil { 81 | log.Fatal(err) 82 | } 83 | openEditor() 84 | commitChangesToHistoryFile(number) 85 | gitPush() 86 | } 87 | -------------------------------------------------------------------------------- /script/bootstrap: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | make deps 3 | -------------------------------------------------------------------------------- /script/build: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | make build 3 | -------------------------------------------------------------------------------- /script/cibuild: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | script/test . 5 | script/build 6 | -------------------------------------------------------------------------------- /script/coverage: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | script/test 4 | COV="coverage.out" 5 | LOCAL_PACKAGE=$(go list) 6 | ruby -e "cov = File.read('$COV'); cov.gsub!(Regexp.new('$LOCAL_PACKAGE'), '.'); File.open('$COV', 'wb'){ |f| f.puts(cov) }" 7 | go tool cover -html=coverage.out 8 | -------------------------------------------------------------------------------- /script/release: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | ./script/cibuild # either it passes the tests, or it doesn't get released 6 | 7 | RELEASE=$(./merge-pr -V | awk '{print $2}') 8 | 9 | echo "Creating release v$RELEASE" 10 | 11 | $EDITOR History.markdown 12 | 13 | git add History.markdown 14 | git commit --allow-empty -m "Release :gem: v$RELEASE" 15 | git tag -m v$RELEASE -a v$RELEASE 16 | 17 | git push 18 | git push --tags 19 | -------------------------------------------------------------------------------- /script/test: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | make test 3 | -------------------------------------------------------------------------------- /shell.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "os/exec" 7 | "strings" 8 | ) 9 | 10 | func commandFromArgs(args ...string) *exec.Cmd { 11 | return exec.Command(args[0], args[1:len(args)]...) 12 | } 13 | 14 | func shellExec(args ...string) error { 15 | if verbose { 16 | log.Println(args) 17 | } 18 | cmd := commandFromArgs(args...) 19 | cmd.Stdin = os.Stdin 20 | cmd.Stdout = os.Stdout 21 | cmd.Stderr = os.Stderr 22 | return cmd.Run() 23 | } 24 | 25 | func shellOutput(args ...string) string { 26 | out, err := commandFromArgs(args...).Output() 27 | if err != nil { 28 | fatalError(err.Error()) 29 | } 30 | return strings.TrimRight(string(out), "\n") 31 | } 32 | -------------------------------------------------------------------------------- /shell_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestShellExec(t *testing.T) { 10 | assert.NoError(t, shellExec("echo")) 11 | } 12 | 13 | func TestShellExecForMultiArgs(t *testing.T) { 14 | assert.NoError(t, shellExec("sh", "-c", "test -f History.markdown")) 15 | } 16 | 17 | func TestShellExecFailingCommand(t *testing.T) { 18 | err := shellExec("sh", "-c", "test", "-d", "History.markdown") 19 | assert.EqualError(t, err, "exit status 1") 20 | } 21 | --------------------------------------------------------------------------------