├── .github ├── dependabot.yml └── workflows │ ├── go.yml │ └── goreleaser.yml ├── .gitignore ├── .goreleaser.yaml ├── LICENSE ├── README.md ├── go.mod ├── go.sum ├── main.go ├── main_test.go └── test.sh /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "gomod" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a golang project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go 3 | 4 | name: Go Build & Test 5 | concurrency: 6 | group: ${{ github.workflow }}-${{ github.ref }} 7 | cancel-in-progress: true 8 | permissions: 9 | contents: read 10 | actions: write 11 | 12 | on: 13 | push: 14 | branches: ["main"] 15 | pull_request: 16 | branches: ["main"] 17 | jobs: 18 | build: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: actions/checkout@v4 22 | 23 | - name: Cache Go modules 24 | uses: actions/cache@v3 25 | with: 26 | path: | 27 | ~/.cache/go-build 28 | ~/go/pkg/mod 29 | key: ${{ runner.os }}-go-modules-${{ hashFiles('**/go.sum') }} 30 | restore-keys: | 31 | ${{ runner.os }}-go-modules- 32 | 33 | - name: Set up Go 34 | uses: actions/setup-go@v4 35 | with: 36 | go-version: "1.23.0" 37 | 38 | - name: Lint with golangci-lint-action 39 | uses: golangci/golangci-lint-action@v4 40 | with: 41 | version: latest 42 | 43 | - name: Build 44 | run: go build -v ./... 45 | 46 | - name: Test 47 | run: go test -v -race -coverprofile=coverage.out ./... 48 | 49 | - name: Upload coverage report 50 | uses: actions/upload-artifact@v4 51 | with: 52 | name: coverage-report 53 | path: coverage.out 54 | -------------------------------------------------------------------------------- /.github/workflows/goreleaser.yml: -------------------------------------------------------------------------------- 1 | # .github/workflows/release.yml 2 | name: goreleaser 3 | 4 | on: 5 | push: 6 | # run only against tags 7 | tags: 8 | - "*" 9 | 10 | permissions: 11 | contents: write 12 | # packages: write 13 | # issues: write 14 | # id-token: write 15 | 16 | jobs: 17 | goreleaser: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - name: Checkout 21 | uses: actions/checkout@v4 22 | with: 23 | fetch-depth: 0 24 | - name: Set up Go 25 | uses: actions/setup-go@v5 26 | with: 27 | go-version: stable 28 | # More assembly might be required: Docker logins, GPG, etc. 29 | # It all depends on your needs. 30 | - name: Run GoReleaser 31 | uses: goreleaser/goreleaser-action@v6 32 | with: 33 | # either 'goreleaser' (default) or 'goreleaser-pro' 34 | distribution: goreleaser 35 | # 'latest', 'nightly', or a semver 36 | version: "~> v2" 37 | args: release --clean 38 | env: 39 | GITHUB_TOKEN: ${{ secrets.PUBLISHER_TOKEN }} 40 | # Your GoReleaser Pro key, if you are using the 'goreleaser-pro' distribution 41 | # GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }} 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Added by goreleaser init: 2 | dist/ 3 | createpr 4 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | # This is an example .goreleaser.yml file with some sensible defaults. 2 | # Make sure to check the documentation at https://goreleaser.com 3 | 4 | # The lines below are called `modelines`. See `:help modeline` 5 | # Feel free to remove those if you don't want/need to use them. 6 | # yaml-language-server: $schema=https://goreleaser.com/static/schema.json 7 | # vim: set ts=2 sw=2 tw=0 fo=cnqoj 8 | 9 | version: 2 10 | 11 | before: 12 | hooks: 13 | # You may remove this if you don't use go modules. 14 | - go mod tidy 15 | # you may remove this if you don't need go generate 16 | - go generate ./... 17 | 18 | builds: 19 | - env: 20 | - CGO_ENABLED=0 21 | goos: 22 | - linux 23 | - windows 24 | - darwin 25 | brews: 26 | - repository: 27 | owner: internetblacksmith 28 | name: homebrew-internetblacksmith 29 | directory: Formula 30 | 31 | archives: 32 | - formats: [tar.gz] 33 | # this name template makes the OS and Arch compatible with the results of `uname`. 34 | name_template: >- 35 | {{ .ProjectName }}_ 36 | {{- title .Os }}_ 37 | {{- if eq .Arch "amd64" }}x86_64 38 | {{- else if eq .Arch "386" }}i386 39 | {{- else }}{{ .Arch }}{{ end }} 40 | {{- if .Arm }}v{{ .Arm }}{{ end }} 41 | # use zip for windows archives 42 | format_overrides: 43 | - goos: windows 44 | formats: [zip] 45 | 46 | changelog: 47 | sort: asc 48 | filters: 49 | exclude: 50 | - "^docs:" 51 | - "^test:" 52 | 53 | release: 54 | footer: >- 55 | 56 | --- 57 | 58 | Released by [GoReleaser](https://github.com/goreleaser/goreleaser). 59 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Internet Blacksmith 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CreatePR 2 | 3 | [![Release](https://img.shields.io/github/release/internetblacksmith/createpr.svg?style=for-the-badge)](https://github.com/internetblacksmith/createpr/releases/latest) 4 | [![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=for-the-badge)](/LICENSE.md) 5 | [![Build status](https://img.shields.io/github/actions/workflow/status/internetblacksmith/createpr/go.yml?style=for-the-badge&branch=main)](https://github.com/goreleaser/goreleaser/actions?workflow=build) 6 | [![Powered By: GoReleaser](https://img.shields.io/badge/powered%20by-goreleaser-green.svg?style=for-the-badge)](https://github.com/goreleaser) 7 | [![GoReportCard](https://goreportcard.com/badge/github.com/internetblacksmith/createpr?style=for-the-badge)](https://goreportcard.com/report/github.com/internetblacksmith/createpr) 8 | 9 | A simple command-line utility that opens GitHub's "New Pull Request" page in your browser directly from your terminal. Skip the manual navigation and instantly create PRs for your current branch. 10 | 11 | ## ✨ Features 12 | 13 | - 🔄 **One Command**: Run `createpr` and immediately open the correct GitHub PR creation page 14 | - 🔍 **Auto-detection**: Automatically detects GitHub repository URL from Git remotes 15 | - 🌿 **Branch Aware**: Uses your current branch as the source branch for the PR 16 | - 🔒 **Secure**: Works with both HTTPS and SSH remote URLs 17 | - 🚀 **Fast**: No dependencies other than Git itself 18 | 19 | ## 📦 Installation 20 | 21 | ### Using Homebrew (macOS) 22 | 23 | ```bash 24 | brew tap internetblacksmith/internetblacksmith 25 | brew install createpr 26 | ``` 27 | 28 | ### Using Scoop (Windows) 29 | 30 | ```bash 31 | scoop bucket add internetblacksmith https://github.com/internetblacksmith/scoop-bucket 32 | scoop install internetblacksmith/createpr 33 | ``` 34 | 35 | ### Using Pre-built Binaries 36 | 37 | 1. Go to the [Releases page](https://github.com/internetblacksmith/createpr/releases) 38 | 2. Download the appropriate binary for your system: 39 | - `createpr_linux_amd64.tar.gz` for Linux (64-bit) 40 | - `createpr_darwin_amd64.tar.gz` for macOS (Intel) 41 | - `createpr_darwin_arm64.tar.gz` for macOS (Apple Silicon) 42 | - `createpr_windows_amd64.zip` for Windows (64-bit) 43 | 3. Extract the archive 44 | 4. Move the `createpr` executable to a directory in your PATH 45 | 46 | ### Using Go Install 47 | 48 | If you have Go installed: 49 | 50 | ```bash 51 | go install github.com/internetblacksmith/createpr@latest 52 | ``` 53 | 54 | ## 🚀 Usage 55 | 56 | Navigate to any Git repository in your terminal and run: 57 | 58 | ```bash 59 | createpr 60 | ``` 61 | 62 | This will: 63 | 1. Detect the GitHub repository URL from your Git remote 64 | 2. Identify your current branch 65 | 3. Open your default web browser to GitHub's "New Pull Request" page with your branch pre-selected 66 | 67 | ### Examples 68 | 69 | ```bash 70 | # Basic usage 71 | cd ~/projects/my-repo 72 | createpr 73 | ``` 74 | 75 | ## 🧪 Development 76 | 77 | ### Prerequisites 78 | 79 | - Go 1.18 or higher 80 | - Git 81 | 82 | ### Building from Source 83 | 84 | ```bash 85 | # Clone the repository 86 | git clone https://github.com/internetblacksmith/createpr.git 87 | cd createpr 88 | 89 | # Build 90 | go build 91 | 92 | # Run tests 93 | go test 94 | ``` 95 | 96 | ## 🤝 Contributing 97 | 98 | Contributions are welcome! Please feel free to submit a Pull Request. 99 | 100 | 1. Fork the repository 101 | 2. Create your feature branch (`git checkout -b feature/amazing-feature`) 102 | 3. Commit your changes (`git commit -m 'Add some amazing feature'`) 103 | 4. Push to the branch (`git push origin feature/amazing-feature`) 104 | 5. Open a Pull Request 105 | 106 | ## 📄 License 107 | 108 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 109 | 110 | ## 🤔 FAQ 111 | 112 | ### ☝️🤓 "Uhm, actually... GitHub CLI can already do this with `gh pr create --web`" 113 | 114 | Yes, and pizza delivery exists too, but sometimes you just want to grab a slice without filling out forms! 115 | 116 | CreatePR is the "no authentication, zero API calls, instant gratification" solution: 117 | 118 | - **Zero login needed** - No "please authenticate" dance required 119 | - **Lightning fast** - Makes absolutely no network requests (the gh CLI makes several) 120 | - **Featherweight champion** - Tiny binary that does one thing perfectly 121 | - **Works offline** - Your spotty coffee shop WiFi can't stop you from getting to that PR page 122 | - **No relationship baggage** - Doesn't need to know your GitHub username, tokens, or life story 123 | 124 | If you're already happily married to the GitHub CLI, that's cool! But for a commitment-free PR creation experience, CreatePR is your speed-dating alternative. 125 | 126 | ### 🔥 Why did you build this when other tools exist? 127 | 128 | I got tired of: 129 | 1. Click repository 130 | 2. Click Pull requests 131 | 3. Click New pull request 132 | 4. Click branch dropdown 133 | 5. Scroll... scroll... scroll... 134 | 6. Click my branch 135 | 7. Question my life choices 136 | 137 | Now it's just `createpr` → *browser opens* → *PR ready to complete* 138 | 139 | ### 🛠️ Will you add feature X? 140 | 141 | Probably not. The beauty of CreatePR is its single-minded focus - it opens PR pages and nothing else. It's not trying to boil the ocean or become your GitHub Swiss Army knife. 142 | 143 | That said, if you have an idea that keeps it simple while improving the core functionality, submit an issue! Just remember: if your feature request includes the phrase "and then it could also..." it's probably a no. 144 | 145 | ### 👻 Does this actually create the PR for me? 146 | 147 | Nope! CreatePR just teleports you to the PR creation page with your current branch pre-selected. You still get to write that thoughtful PR description your colleagues will definitely read thoroughly. 148 | 149 | Think of it as an express elevator to the PR floor, not an automated PR robot. 150 | 151 | ## 🙏 Acknowledgments 152 | 153 | - Inspired by a rant from [Theo](https://x.com/theo) in one of his streams 154 | - Built with [Go](https://golang.org/) 155 | - Released with [GoReleaser](https://goreleaser.com/) 156 | 157 | --- 158 | 159 |

160 | Made with ❤️ by Internet Blacksmith 161 |

162 | ``` 163 | 164 | ## Planned improvements 165 | 166 | - [ ] Add option to open the extended PR view 167 | - [ ] Handle other services BitBucket/Gitlab etc... 168 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/internetblacksmith/createpr 2 | 3 | go 1.23.0 4 | 5 | require github.com/go-git/go-git/v5 v5.16.0 6 | 7 | require ( 8 | dario.cat/mergo v1.0.0 // indirect 9 | github.com/Microsoft/go-winio v0.6.2 // indirect 10 | github.com/ProtonMail/go-crypto v1.1.6 // indirect 11 | github.com/cloudflare/circl v1.6.1 // indirect 12 | github.com/cyphar/filepath-securejoin v0.4.1 // indirect 13 | github.com/emirpasic/gods v1.18.1 // indirect 14 | github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect 15 | github.com/go-git/go-billy/v5 v5.6.2 // indirect 16 | github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect 17 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect 18 | github.com/kevinburke/ssh_config v1.2.0 // indirect 19 | github.com/pjbgf/sha1cd v0.3.2 // indirect 20 | github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect 21 | github.com/skeema/knownhosts v1.3.1 // indirect 22 | github.com/xanzy/ssh-agent v0.3.3 // indirect 23 | golang.org/x/crypto v0.37.0 // indirect 24 | golang.org/x/net v0.39.0 // indirect 25 | golang.org/x/sys v0.32.0 // indirect 26 | gopkg.in/warnings.v0 v0.1.2 // indirect 27 | ) 28 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= 2 | dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= 3 | github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= 4 | github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= 5 | github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= 6 | github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= 7 | github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= 8 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= 9 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= 10 | github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= 11 | github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= 12 | github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= 13 | github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= 14 | github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= 15 | github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= 16 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 17 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 18 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 19 | github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= 20 | github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= 21 | github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= 22 | github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= 23 | github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= 24 | github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= 25 | github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= 26 | github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= 27 | github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM= 28 | github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU= 29 | github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= 30 | github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= 31 | github.com/go-git/go-git/v5 v5.16.0 h1:k3kuOEpkc0DeY7xlL6NaaNg39xdgQbtH5mwCafHO9AQ= 32 | github.com/go-git/go-git/v5 v5.16.0/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8= 33 | github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= 34 | github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= 35 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 36 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 37 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= 38 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= 39 | github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= 40 | github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= 41 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 42 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 43 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 44 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 45 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 46 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 47 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 48 | github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= 49 | github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= 50 | github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= 51 | github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= 52 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 53 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 54 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 55 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 56 | github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= 57 | github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= 58 | github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= 59 | github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= 60 | github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= 61 | github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= 62 | github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= 63 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 64 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 65 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 66 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 67 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 68 | github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= 69 | github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= 70 | golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= 71 | golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= 72 | golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= 73 | golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= 74 | golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= 75 | golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 76 | golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= 77 | golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= 78 | golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 79 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 80 | golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 81 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 82 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 83 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 84 | golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= 85 | golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 86 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 87 | golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= 88 | golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= 89 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 90 | golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= 91 | golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= 92 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 93 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 94 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 95 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 96 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 97 | gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= 98 | gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= 99 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 100 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 101 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 102 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 103 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024 Internet Blacksmith 2 | // This source code is licensed under the MIT license found in the 3 | // LICENSE file in the root directory of this source tree. 4 | 5 | package main 6 | 7 | import ( 8 | "fmt" 9 | "log" 10 | "net/url" 11 | "os/exec" 12 | "runtime" 13 | "strings" 14 | 15 | "github.com/go-git/go-git/v5" 16 | ) 17 | 18 | // TODO: add option to open expanded dif by appending ?`expand=1` at the end of the URL 19 | func main() { 20 | repo, err := git.PlainOpenWithOptions(".", &git.PlainOpenOptions{ 21 | DetectDotGit: true, 22 | }) 23 | if err != nil { 24 | log.Fatalf("Failed to open git repository: %v", err) 25 | } 26 | 27 | remoteName := "origin" 28 | remote, err := repo.Remote(remoteName) 29 | if err != nil { 30 | log.Fatalf("Failed to get remote '%s': %v", remoteName, err) 31 | } 32 | 33 | remoteURL := remote.Config().URLs[0] 34 | githubURL, err := parseGithubURL(remoteURL) 35 | if err != nil { 36 | log.Fatalf("Failed to parse GitHub URL: %v", err) 37 | } 38 | 39 | headRef, err := repo.Head() 40 | if err != nil { 41 | log.Fatalf("Failed to get HEAD reference: %v", err) 42 | } 43 | currentBranch := headRef.Name().Short() 44 | 45 | newPRURL := fmt.Sprintf("%s/compare/%s", githubURL, url.PathEscape(currentBranch)) 46 | 47 | fmt.Printf("Opening: %s\n", newPRURL) 48 | err = openBrowser(newPRURL) 49 | if err != nil { 50 | log.Fatalf("Failed to open browser: %v", err) 51 | } 52 | } 53 | 54 | func parseGithubURL(remoteURL string) (string, error) { 55 | if strings.HasPrefix(remoteURL, "git@") { 56 | // SSH URL format: git@github.com:owner/repo.git 57 | parts := strings.SplitN(remoteURL, ":", 2) 58 | if len(parts) < 2 { 59 | return "", fmt.Errorf("invalid SSH remote URL format") 60 | } 61 | repoPath := strings.TrimSuffix(parts[1], ".git") 62 | return fmt.Sprintf("https://github.com/%s", repoPath), nil 63 | } else if strings.HasPrefix(remoteURL, "http://") || strings.HasPrefix(remoteURL, "https://") { 64 | // HTTP/HTTPS URL format: https://github.com/owner/repo.git 65 | trimmedURL := strings.TrimSuffix(remoteURL, ".git") 66 | return trimmedURL, nil 67 | } else { 68 | return "", fmt.Errorf("unsupported remote URL format") 69 | } 70 | } 71 | 72 | // Create a variable for the OS getter to make testing easier 73 | var getOS = func() string { 74 | return runtime.GOOS 75 | } 76 | 77 | // Make exec.Command replaceable for testing 78 | var execCommand = exec.Command 79 | 80 | func openBrowser(url string) error { 81 | var cmd *exec.Cmd 82 | 83 | switch os := getOS(); os { 84 | case "windows": 85 | cmd = execCommand("cmd", "/c", "start", url) 86 | case "darwin": 87 | cmd = execCommand("open", url) 88 | default: // "linux", "freebsd", "openbsd", "netbsd" 89 | cmd = execCommand("xdg-open", url) 90 | } 91 | 92 | return cmd.Run() 93 | } 94 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os/exec" 5 | "reflect" 6 | "testing" 7 | ) 8 | 9 | func TestParseGithubURL(t *testing.T) { 10 | testCases := []struct { 11 | name string 12 | inputURL string 13 | expectedURL string 14 | expectError bool 15 | expectedErrMsg string 16 | }{ 17 | { 18 | name: "Valid HTTPS URL", 19 | inputURL: "https://github.com/owner/repo.git", 20 | expectedURL: "https://github.com/owner/repo", 21 | expectError: false, 22 | expectedErrMsg: "", 23 | }, 24 | { 25 | name: "Valid SSH URL", 26 | inputURL: "git@github.com:owner/repo.git", 27 | expectedURL: "https://github.com/owner/repo", 28 | expectError: false, 29 | expectedErrMsg: "", 30 | }, 31 | { 32 | name: "Invalid URL", 33 | inputURL: "invalid-url", 34 | expectedURL: "", 35 | expectError: true, 36 | expectedErrMsg: "unsupported remote URL format", 37 | }, 38 | { 39 | name: "HTTPS URL without .git", 40 | inputURL: "https://github.com/owner/repo", 41 | expectedURL: "https://github.com/owner/repo", 42 | expectError: false, 43 | expectedErrMsg: "", 44 | }, 45 | } 46 | 47 | for _, tc := range testCases { 48 | t.Run(tc.name, func(t *testing.T) { 49 | actualURL, err := parseGithubURL(tc.inputURL) 50 | if tc.expectError { 51 | if err == nil { 52 | t.Errorf("Expected error for %s, but got none", tc.inputURL) 53 | } 54 | if err.Error() != tc.expectedErrMsg { 55 | t.Errorf("Expected error message %q, but got %q", tc.expectedErrMsg, err.Error()) 56 | } 57 | } else { 58 | if err != nil { 59 | t.Errorf("Unexpected error for %s: %v", tc.inputURL, err) 60 | } 61 | if actualURL != tc.expectedURL { 62 | t.Errorf("For %s, expected URL %q, but got %q", tc.inputURL, tc.expectedURL, actualURL) 63 | } 64 | } 65 | }) 66 | } 67 | } 68 | 69 | // Mock command execution 70 | type MockCommand struct { 71 | cmd string 72 | args []string 73 | } 74 | 75 | func TestOpenBrowser(t *testing.T) { 76 | // Save original function and restore after test 77 | originalExecCommand := execCommand 78 | originalGetOS := getOS 79 | defer func() { 80 | execCommand = originalExecCommand 81 | getOS = originalGetOS 82 | }() 83 | 84 | testCases := []struct { 85 | name string 86 | goos string 87 | url string 88 | expectedCmd string 89 | expectedArgs []string 90 | }{ 91 | { 92 | name: "Windows", 93 | goos: "windows", 94 | url: "https://github.com/owner/repo/compare/branch", 95 | expectedCmd: "cmd", 96 | expectedArgs: []string{"/c", "start", "https://github.com/owner/repo/compare/branch"}, 97 | }, 98 | { 99 | name: "macOS", 100 | goos: "darwin", 101 | url: "https://github.com/owner/repo/compare/branch", 102 | expectedCmd: "open", 103 | expectedArgs: []string{"https://github.com/owner/repo/compare/branch"}, 104 | }, 105 | { 106 | name: "Linux", 107 | goos: "linux", 108 | url: "https://github.com/owner/repo/compare/branch", 109 | expectedCmd: "xdg-open", 110 | expectedArgs: []string{"https://github.com/owner/repo/compare/branch"}, 111 | }, 112 | { 113 | name: "Default OS", 114 | goos: "something-else", 115 | url: "https://github.com/owner/repo/compare/branch", 116 | expectedCmd: "xdg-open", 117 | expectedArgs: []string{"https://github.com/owner/repo/compare/branch"}, 118 | }, 119 | } 120 | 121 | for _, tc := range testCases { 122 | t.Run(tc.name, func(t *testing.T) { 123 | // Mock out command execution 124 | var mock MockCommand 125 | execCommand = func(command string, args ...string) *exec.Cmd { 126 | mock.cmd = command 127 | mock.args = args 128 | // Return a dummy command that succeeds 129 | return exec.Command("echo", "success") 130 | } 131 | 132 | // Set OS environment 133 | getOS = func() string { 134 | return tc.goos 135 | } 136 | 137 | // Call the function we're testing 138 | err := openBrowser(tc.url) 139 | if err != nil { 140 | t.Errorf("openBrowser returned unexpected error: %v", err) 141 | } 142 | 143 | // Verify the correct command was used 144 | if mock.cmd != tc.expectedCmd { 145 | t.Errorf("Expected command %q, got %q", tc.expectedCmd, mock.cmd) 146 | } 147 | 148 | // Verify the arguments 149 | if !reflect.DeepEqual(mock.args, tc.expectedArgs) { 150 | t.Errorf("Expected args %v, got %v", tc.expectedArgs, mock.args) 151 | } 152 | }) 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | go test -json -v ./... 2>&1 | tee /tmp/gotest.log | gotestfmt 4 | --------------------------------------------------------------------------------