Repository: mislav/hub Branch: master Commit: 5c547ed80436 Files: 211 Total size: 745.7 KB Directory structure: gitextract_4bom6jnl/ ├── .ctags.d/ │ └── go.ctags ├── .dockerignore ├── .gitattributes ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ └── feature_request.md │ ├── SECURITY.md │ ├── dependabot.yml │ └── workflows/ │ ├── ci.yml │ └── release.yml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Dockerfile ├── Gemfile ├── LICENSE ├── Makefile ├── README.md ├── cmd/ │ ├── cmd.go │ └── cmd_test.go ├── commands/ │ ├── alias.go │ ├── api.go │ ├── apply.go │ ├── args.go │ ├── args_test.go │ ├── browse.go │ ├── checkout.go │ ├── cherry_pick.go │ ├── ci_status.go │ ├── clone.go │ ├── commands.go │ ├── commands_test.go │ ├── compare.go │ ├── compare_test.go │ ├── create.go │ ├── delete.go │ ├── fetch.go │ ├── fetch_test.go │ ├── fork.go │ ├── gist.go │ ├── help.go │ ├── init.go │ ├── init_test.go │ ├── issue.go │ ├── issue_test.go │ ├── merge.go │ ├── pr.go │ ├── pull_request.go │ ├── pull_request_test.go │ ├── push.go │ ├── push_test.go │ ├── release.go │ ├── remote.go │ ├── remote_test.go │ ├── runner.go │ ├── runner_test.go │ ├── submodule.go │ ├── sync.go │ ├── utils.go │ ├── utils_test.go │ └── version.go ├── coverage/ │ └── coverage.go ├── cucumber.yml ├── etc/ │ ├── README.md │ ├── hub.bash_completion.sh │ ├── hub.fish_completion │ └── hub.zsh_completion ├── features/ │ ├── README.md │ ├── alias.feature │ ├── am.feature │ ├── api.feature │ ├── apply.feature │ ├── authentication.feature │ ├── bash_completion.feature │ ├── browse.feature │ ├── checkout.feature │ ├── cherry_pick.feature │ ├── ci_status.feature │ ├── clone.feature │ ├── compare.feature │ ├── create.feature │ ├── delete.feature │ ├── fetch.feature │ ├── fish_completion.feature │ ├── fork.feature │ ├── gist.feature │ ├── git_compatibility.feature │ ├── help.feature │ ├── init.feature │ ├── issue-transfer.feature │ ├── issue.feature │ ├── merge.feature │ ├── pr-checkout.feature │ ├── pr-list.feature │ ├── pr-merge.feature │ ├── pr-show.feature │ ├── pull_request.feature │ ├── push.feature │ ├── release.feature │ ├── remote_add.feature │ ├── steps.rb │ ├── submodule_add.feature │ ├── support/ │ │ ├── completion.rb │ │ ├── env.rb │ │ ├── fakebin/ │ │ │ ├── curl │ │ │ ├── git │ │ │ ├── man │ │ │ └── open │ │ ├── local_server.rb │ │ └── rspec_matchers.rb │ ├── sync.feature │ └── zsh_completion.feature ├── fixtures/ │ ├── fixtures.go │ ├── release_dir/ │ │ ├── dir/ │ │ │ ├── file2 │ │ │ ├── file3 │ │ │ └── subdir/ │ │ │ └── file4 │ │ └── file1 │ ├── test.git/ │ │ ├── HEAD │ │ ├── config │ │ ├── description │ │ ├── hooks/ │ │ │ ├── applypatch-msg.sample │ │ │ ├── commit-msg.sample │ │ │ ├── post-update.sample │ │ │ ├── pre-applypatch.sample │ │ │ ├── pre-commit.sample │ │ │ ├── pre-push.sample │ │ │ ├── pre-rebase.sample │ │ │ ├── prepare-commit-msg.sample │ │ │ └── update.sample │ │ ├── info/ │ │ │ └── exclude │ │ ├── objects/ │ │ │ ├── 08/ │ │ │ │ └── f4b7b6513dffc6245857e497cfd6101dc47818 │ │ │ ├── 8a/ │ │ │ │ └── 1cdac440b4a3c44b988e300758a903a9866905 │ │ │ ├── 9b/ │ │ │ │ └── 5a719a3d76ac9dc2fa635d9b1f34fd73994c06 │ │ │ ├── 9d/ │ │ │ │ └── aeafb9864cf43055ae93beb0afd6c7d144bfa4 │ │ │ ├── ca/ │ │ │ │ └── 93b49848670d03b3968c8a481eca55f5fb2150 │ │ │ └── e6/ │ │ │ └── 9de29bb2d1d6434b8b29ae775ad8c2e48c5391 │ │ └── refs/ │ │ └── heads/ │ │ └── master │ ├── test_configs.go │ └── test_repo.go ├── git/ │ ├── git.go │ ├── git_test.go │ ├── ssh_config.go │ ├── ssh_config_test.go │ ├── url.go │ └── url_test.go ├── github/ │ ├── branch.go │ ├── branch_test.go │ ├── client.go │ ├── client_test.go │ ├── config.go │ ├── config_decoder.go │ ├── config_encoder.go │ ├── config_service.go │ ├── config_service_test.go │ ├── crash_report.go │ ├── crash_report_test.go │ ├── editor.go │ ├── editor_test.go │ ├── hosts.go │ ├── http.go │ ├── http_test.go │ ├── localrepo.go │ ├── localrepo_test.go │ ├── message_builder.go │ ├── message_builder_test.go │ ├── project.go │ ├── project_test.go │ ├── remote.go │ ├── remote_test.go │ ├── reset_console.go │ ├── reset_console_windows.go │ ├── template.go │ ├── template_test.go │ ├── url.go │ └── url_test.go ├── go.mod ├── go.sum ├── internal/ │ └── assert/ │ └── assert.go ├── main.go ├── man-template.html ├── md2roff/ │ └── renderer.go ├── md2roff-bin/ │ └── cmd.go ├── script/ │ ├── bootstrap │ ├── build │ ├── build.bat │ ├── changelog │ ├── coverage │ ├── cross-compile │ ├── docker │ ├── get │ ├── github-release │ ├── install.bat │ ├── install.sh │ ├── package │ ├── publish-release │ ├── ruby-test │ ├── tag-release │ ├── test │ ├── version │ └── version.bat ├── share/ │ └── vim/ │ └── vimfiles/ │ ├── ftdetect/ │ │ └── pullrequest.vim │ └── syntax/ │ └── pullrequest.vim ├── ui/ │ ├── format.go │ ├── format_test.go │ └── ui.go ├── utils/ │ ├── args_parser.go │ ├── args_parser_test.go │ ├── color.go │ ├── json.go │ ├── utils.go │ └── utils_test.go └── version/ └── version.go ================================================ FILE CONTENTS ================================================ ================================================ FILE: .ctags.d/go.ctags ================================================ -R --exclude=etc --exclude=script --exclude=site --exclude=tmp --exclude=vendor --exclude=bundle ================================================ FILE: .dockerignore ================================================ * !Gemfile !Gemfile.lock ================================================ FILE: .gitattributes ================================================ # enforce correct line endings for shell and batch files. *.sh text eol=lf script/* text eol=lf script/*.bat text eol=crlf ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug report about: Unexpected or broken behavior of "hub" command-line tool title: '' labels: bug assignees: '' --- **Command attempted:** **What happened:** **More info:** ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- name: Feature request about: Suggest new functionality for "hub" command-line tool title: '' labels: feature assignees: '' --- **The problem I'm trying to solve:** **How I imagine hub could expose this functionality:** ================================================ FILE: .github/SECURITY.md ================================================ Please report security vulnerabilities to mislav@github.com. Thank you! Note that, unlike the [GitHub CLI](https://github.com/cli/cli), `hub` is _not_ an eligible target for the [GitHub Bug Bounty program](https://hackerone.com/github). ================================================ FILE: .github/dependabot.yml ================================================ version: 2 updates: - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" - package-ecosystem: "bundler" directory: "/" schedule: interval: "weekly" - package-ecosystem: "gomod" directory: "/" schedule: interval: "weekly" ignore: - dependency-name: "*" update-types: - version-update:semver-major ================================================ FILE: .github/workflows/ci.yml ================================================ name: CI on: [push, pull_request] jobs: build: runs-on: ubuntu-latest env: BUNDLE_BIN: bin steps: - uses: actions/checkout@v4 - name: Set up Ruby uses: ruby/setup-ruby@v1 with: ruby-version: "2.6" bundler-cache: true - name: Set up Go uses: actions/setup-go@v4 with: go-version: "1.18" - name: Run tests run: make test-all env: CI: true ================================================ FILE: .github/workflows/release.yml ================================================ name: Release on: push: tags: "v*" jobs: release: name: Publish release runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Go uses: actions/setup-go@v4 with: go-version: "1.18" - name: Publish release script run: script/publish-release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - uses: mislav/bump-homebrew-formula-action@v3 if: "!contains(github.ref, '-')" # skip prereleases with: formula-name: hub env: COMMITTER_TOKEN: ${{ secrets.COMMITTER_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .gitignore ================================================ *.swp *~ /bin .bundle bundle/ share/doc/* share/man/* !share/man/man1/hub.1.md tmp/ *.test target .vagrant tags /site /hub .vscode .DS_Store ================================================ FILE: CODE_OF_CONDUCT.md ================================================ # Contributor Covenant Code of Conduct ## Our Pledge In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. ## Our Standards Examples of behavior that contributes to creating a positive environment include: * Using welcoming and inclusive language * Being respectful of differing viewpoints and experiences * Gracefully accepting constructive criticism * Focusing on what is best for the community * Showing empathy towards other community members Examples of unacceptable behavior by participants include: * The use of sexualized language or imagery and unwelcome sexual attention or advances * Trolling, insulting/derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or electronic address, without explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Our Responsibilities Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. ## Scope This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at opensource+hub@github.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] [homepage]: http://contributor-covenant.org [version]: http://contributor-covenant.org/version/1/4/ ================================================ FILE: CONTRIBUTING.md ================================================ Contributing to hub =================== Contributions to this project are [released](https://help.github.com/articles/github-terms-of-service/#6-contributions-under-repository-license) to the public under the [project's open source license](LICENSE). This project adheres to a [Code of Conduct][code-of-conduct]. By participating, you are expected to uphold this code. [code-of-conduct]: ./CODE_OF_CONDUCT.md You will need: 1. Go 1.11+ 1. Ruby 1.9+ with Bundler 2. git 1.8+ 3. tmux & zsh (optional) - for running shell completion tests If setting up either Go or Ruby for development proves to be a pain, you can run the test suite in a prepared Docker container via `script/docker`. ## What makes a good hub feature hub is a tool that wraps git to provide useful integration with GitHub. A new feature is a good idea for hub if it improves some workflow for a GitHub user. * A feature that encapsulates a git workflow *not specific* to GitHub is **not** a good fit for hub, since something like that is best implemented as an external script. * If you're proposing to add a new custom command such as `hub foo`, please research if there's a possibility that such a custom command could conflict with other commands from popular 3rd party git projects. * If your contribution fixes a security vulnerability, please refer to the [SECURITY.md](./.github/SECURITY.md) security policy file ## How to install dependencies and run tests 1. [Clone the project](./README.md#source) 2. Verify that existing tests pass: `make test-all` 3. Create a topic branch: `git checkout -b feature` 4. **Make your changes.** (It helps a lot if you write tests first.) 5. Verify that the tests still pass. 6. Fork the project on GitHub: `make && bin/hub fork --remote-name=origin` 7. Push to your fork: `git push -u origin HEAD` 8. Open a pull request describing your changes: `bin/hub pull-request` Vendored Go dependencies are managed with [`go mod`](https://github.com/golang/go/wiki/Modules). Check `go help mod` for information on how to add or update a vendored dependency. ## How to write tests Go unit tests are in `*_test.go` files and are runnable with `make test`. These run really fast (under 10s). However, most hub functionality is exercised through integration-style tests written in Cucumber. See [Features](./features) for more info. ================================================ FILE: Dockerfile ================================================ FROM ruby:2.6 RUN apt-get update \ && apt-get install -y sudo golang --no-install-recommends RUN apt-get purge --auto-remove -y curl \ && rm -rf /var/lib/apt/lists/* RUN groupadd -r app && useradd -r -g app -G sudo app \ && mkdir -p /home/app && chown -R app:app /home/app RUN echo '%sudo ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers USER app # throw errors if Gemfile has been modified since Gemfile.lock RUN bundle config --global frozen 1 WORKDIR /home/app/workdir COPY Gemfile Gemfile.lock ./ RUN bundle install ENV LANG C.UTF-8 ENV USER app ================================================ FILE: Gemfile ================================================ source 'https://rubygems.org' gem 'aruba', '~> 1.0.4' gem 'cucumber', '~> 3.1.2' gem 'sinatra' ================================================ FILE: LICENSE ================================================ Copyright (c) 2009 Chris Wanstrath Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: Makefile ================================================ SOURCES = $(shell go list -f '{{range .GoFiles}}{{$$.Dir}}/{{.}}\ {{end}}' ./...) SOURCE_DATE_EPOCH ?= $(shell date +%s) BUILD_DATE = $(shell date -u -d "@$(SOURCE_DATE_EPOCH)" '+%d %b %Y' 2>/dev/null || date -u -r "$(SOURCE_DATE_EPOCH)" '+%d %b %Y') HUB_VERSION = $(shell bin/hub version | tail -1) export GO111MODULE=on unexport GOPATH export LDFLAGS := -extldflags '$(LDFLAGS)' export GCFLAGS := all=-trimpath '$(PWD)' export ASMFLAGS := all=-trimpath '$(PWD)' MIN_COVERAGE = 90.2 HELP_CMD = \ share/man/man1/hub-alias.1 \ share/man/man1/hub-api.1 \ share/man/man1/hub-browse.1 \ share/man/man1/hub-ci-status.1 \ share/man/man1/hub-compare.1 \ share/man/man1/hub-create.1 \ share/man/man1/hub-delete.1 \ share/man/man1/hub-fork.1 \ share/man/man1/hub-gist.1 \ share/man/man1/hub-pr.1 \ share/man/man1/hub-pull-request.1 \ share/man/man1/hub-release.1 \ share/man/man1/hub-issue.1 \ share/man/man1/hub-sync.1 \ HELP_EXT = \ share/man/man1/hub-am.1 \ share/man/man1/hub-apply.1 \ share/man/man1/hub-checkout.1 \ share/man/man1/hub-cherry-pick.1 \ share/man/man1/hub-clone.1 \ share/man/man1/hub-fetch.1 \ share/man/man1/hub-help.1 \ share/man/man1/hub-init.1 \ share/man/man1/hub-merge.1 \ share/man/man1/hub-push.1 \ share/man/man1/hub-remote.1 \ share/man/man1/hub-submodule.1 \ HELP_ALL = share/man/man1/hub.1 $(HELP_CMD) $(HELP_EXT) TEXT_WIDTH = 87 bin/hub: $(SOURCES) script/build -o $@ bin/md2roff: $(SOURCES) go build -o $@ github.com/github/hub/v2/md2roff-bin test: go test ./... test-all: bin/cucumber ifdef CI script/test --coverage $(MIN_COVERAGE) else script/test endif bin/cucumber: script/bootstrap fmt: go fmt ./... man-pages: $(HELP_ALL:=.md) $(HELP_ALL) $(HELP_ALL:=.txt) %.txt: % groff -Wall -mtty-char -mandoc -Tutf8 -rLL=$(TEXT_WIDTH)n $< | col -b >$@ $(HELP_ALL): share/man/.man-pages.stamp share/man/.man-pages.stamp: $(HELP_ALL:=.md) ./man-template.html bin/md2roff bin/md2roff --manual="hub manual" \ --date="$(BUILD_DATE)" --version="$(HUB_VERSION)" \ --template=./man-template.html \ share/man/man1/*.md mkdir -p share/doc/hub-doc mv share/man/*/*.html share/doc/hub-doc/ touch $@ %.1.md: bin/hub bin/hub help $(*F) --plain-text >$@ share/man/man1/hub.1.md: true install: bin/hub man-pages bash < script/install.sh clean: git clean -fdx bin share/man .PHONY: clean test test-all man-pages fmt install ================================================ FILE: README.md ================================================ hub is a command line tool that wraps `git` in order to extend it with extra features and commands that make working with GitHub easier. For an official, potentially more user-friendly command-line interface to GitHub, see [cli.github.com](https://cli.github.com) and [this comparison](https://github.com/cli/cli/blob/trunk/docs/gh-vs-hub.md). This repository and its issue tracker is **not for reporting problems with GitHub.com** web interface. If you have a problem with GitHub itself, please [contact Support](https://github.com/contact). Usage ----- ``` sh $ hub clone rtomayko/tilt #=> git clone https://github.com/rtomayko/tilt.git # or, if you prefer the SSH protocol: $ git config --global hub.protocol ssh $ hub clone rtomayko/tilt #=> git clone git@github.com:rtomayko/tilt.git ``` See [usage examples](https://hub.github.com/#developer) or the [full reference documentation](https://hub.github.com/hub.1.html) to see all available commands and flags. hub can also be used to make shell scripts that [directly interact with the GitHub API](https://hub.github.com/#scripting). hub can be safely [aliased](#aliasing) as `git`, so you can type `$ git ` in the shell and have it expanded with `hub` features. Installation ------------ The `hub` executable has no dependencies, but since it was designed to wrap `git`, it's recommended to have at least **git 1.7.3** or newer. platform | manager | command to run ---------|---------|--------------- macOS, Linux | [Homebrew](https://docs.brew.sh/Installation) | `brew install hub` macOS, Linux | [Nix](https://nixos.org/) | `nix-env -i hub` Windows | [Scoop](http://scoop.sh/) | `scoop install hub` Windows | [Chocolatey](https://chocolatey.org/) | `choco install hub` Fedora Linux | [DNF](https://fedoraproject.org/wiki/DNF) | `sudo dnf install hub` Arch Linux | [pacman](https://wiki.archlinux.org/index.php/pacman) | `sudo pacman -S hub` FreeBSD | [pkg(8)](http://man.freebsd.org/pkg/8) | `pkg install hub` Debian, Ubuntu | [apt(8)](https://manpages.debian.org/buster/apt/apt.8.en.html) | `sudo apt install hub` Ubuntu | [Snap](https://snapcraft.io) | [We do not recommend installing the snap anymore.](https://github.com/github/hub/issues?q=is%3Aissue+snap) openSUSE | [Zypper](https://en.opensuse.org/SDB:Zypper_manual) | `sudo zypper install hub` Void Linux | [xbps](https://github.com/void-linux/xbps) | `sudo xbps-install -S hub` Gentoo | [Portage](https://wiki.gentoo.org/wiki/Portage) | `sudo emerge dev-vcs/hub` _any_ | [conda](https://docs.conda.io/en/latest/) | `conda install -c conda-forge hub` Packages other than Homebrew are community-maintained (thank you!) and they are not guaranteed to match the [latest hub release][latest]. Check `hub version` after installing a community package. #### Standalone `hub` can be easily installed as an executable. Download the [latest binary][latest] for your system and put it anywhere in your executable path. #### GitHub Actions hub is ready to be used in your [GitHub Actions][] workflows: ```yaml steps: - uses: actions/checkout@v2 - name: List open pull requests run: hub pr list env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ``` Note that the default `secrets.GITHUB_TOKEN` will only work for API operations scoped to the repository that runs this workflow. If you need to interact with other repositories, [generate a Personal Access Token][pat] with at least the `repo` scope and add it to your [repository secrets][]. [github actions]: https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions [pat]: https://github.com/settings/tokens [repository secrets]: https://docs.github.com/en/actions/configuring-and-managing-workflows/creating-and-storing-encrypted-secrets #### Source Prerequisites for building from source are: * `make` * [Go 1.11+](https://golang.org/doc/install) Clone this repository and run `make install`: ```sh git clone \ --config transfer.fsckobjects=false \ --config receive.fsckobjects=false \ --config fetch.fsckobjects=false \ https://github.com/github/hub.git cd hub make install prefix=/usr/local ``` Aliasing -------- Some hub features feel best when it's aliased as `git`. This is not dangerous; your _normal git commands will all work_. hub merely adds some sugar. `hub alias` displays instructions for the current shell. With the `-s` flag, it outputs a script suitable for `eval`. You should place this command in your `.bash_profile` or other startup script: ``` sh eval "$(hub alias -s)" ``` #### PowerShell If you're using PowerShell, you can set an alias for `hub` by placing the following in your PowerShell profile (usually `~/Documents/WindowsPowerShell/Microsoft.PowerShell_profile.ps1`): ``` sh Set-Alias git hub ``` A simple way to do this is to run the following from the PowerShell prompt: ``` sh Add-Content $PROFILE "`nSet-Alias git hub" ``` Note: You'll need to restart your PowerShell console in order for the changes to be picked up. If your PowerShell profile doesn't exist, you can create it by running the following: ``` sh New-Item -Type file -Force $PROFILE ``` ### Shell tab-completion hub repository contains [tab-completion scripts](./etc) for bash, zsh and fish. These scripts complement existing completion scripts that ship with git. Meta ---- * Bugs: * Authors: * Our [Code of Conduct](https://github.com/github/hub/blob/master/CODE_OF_CONDUCT.md) [latest]: https://github.com/github/hub/releases/latest ================================================ FILE: cmd/cmd.go ================================================ package cmd import ( "fmt" "os" "os/exec" "runtime" "strings" "syscall" "github.com/github/hub/v2/ui" ) // Cmd is a project-wide struct that represents a command to be run in the console. type Cmd struct { Name string Args []string Stdin *os.File Stdout *os.File Stderr *os.File } func (cmd Cmd) String() string { args := make([]string, len(cmd.Args)) for i, a := range cmd.Args { if strings.ContainsRune(a, '"') { args[i] = fmt.Sprintf(`'%s'`, a) } else if a == "" || strings.ContainsRune(a, '\'') || strings.ContainsRune(a, ' ') { args[i] = fmt.Sprintf(`"%s"`, a) } else { args[i] = a } } return fmt.Sprintf("%s %s", cmd.Name, strings.Join(args, " ")) } // WithArg returns the current argument func (cmd *Cmd) WithArg(arg string) *Cmd { cmd.Args = append(cmd.Args, arg) return cmd } func (cmd *Cmd) WithArgs(args ...string) *Cmd { for _, arg := range args { cmd.WithArg(arg) } return cmd } func (cmd *Cmd) Output() (string, error) { verboseLog(cmd) c := exec.Command(cmd.Name, cmd.Args...) c.Stderr = cmd.Stderr output, err := c.Output() return string(output), err } func (cmd *Cmd) CombinedOutput() (string, error) { verboseLog(cmd) output, err := exec.Command(cmd.Name, cmd.Args...).CombinedOutput() return string(output), err } func (cmd *Cmd) Success() bool { verboseLog(cmd) err := exec.Command(cmd.Name, cmd.Args...).Run() return err == nil } // Run runs command with `Exec` on platforms except Windows // which only supports `Spawn` func (cmd *Cmd) Run() error { if isWindows() { return cmd.Spawn() } return cmd.Exec() } func isWindows() bool { return runtime.GOOS == "windows" || detectWSL() } var detectedWSL bool var detectedWSLContents string // https://github.com/Microsoft/WSL/issues/423#issuecomment-221627364 func detectWSL() bool { if !detectedWSL { b := make([]byte, 1024) f, err := os.Open("/proc/version") if err == nil { f.Read(b) f.Close() detectedWSLContents = string(b) } detectedWSL = true } return strings.Contains(detectedWSLContents, "Microsoft") } // Spawn runs command with spawn(3) func (cmd *Cmd) Spawn() error { verboseLog(cmd) c := exec.Command(cmd.Name, cmd.Args...) c.Stdin = cmd.Stdin c.Stdout = cmd.Stdout c.Stderr = cmd.Stderr return c.Run() } // Exec runs command with exec(3) // Note that Windows doesn't support exec(3): http://golang.org/src/pkg/syscall/exec_windows.go#L339 func (cmd *Cmd) Exec() error { verboseLog(cmd) binary, err := exec.LookPath(cmd.Name) if err != nil { return &exec.Error{ Name: cmd.Name, Err: fmt.Errorf("command not found"), } } args := []string{binary} args = append(args, cmd.Args...) return syscall.Exec(binary, args, os.Environ()) } func New(name string) *Cmd { return &Cmd{ Name: name, Args: []string{}, Stdin: os.Stdin, Stdout: os.Stdout, Stderr: os.Stderr, } } func NewWithArray(cmd []string) *Cmd { return &Cmd{Name: cmd[0], Args: cmd[1:], Stdin: os.Stdin, Stdout: os.Stdout, Stderr: os.Stderr} } func verboseLog(cmd *Cmd) { if os.Getenv("HUB_VERBOSE") != "" { msg := fmt.Sprintf("$ %s", cmd.String()) if ui.IsTerminal(os.Stderr) { msg = fmt.Sprintf("\033[35m%s\033[0m", msg) } ui.Errorln(msg) } } ================================================ FILE: cmd/cmd_test.go ================================================ package cmd import ( "testing" "github.com/github/hub/v2/internal/assert" ) func TestNew(t *testing.T) { execCmd := New("vim --noplugin") assert.Equal(t, "vim --noplugin", execCmd.Name) assert.Equal(t, 0, len(execCmd.Args)) } func TestWithArg(t *testing.T) { execCmd := New("git") execCmd.WithArg("command").WithArg("--amend").WithArg("-m").WithArg(`""`) assert.Equal(t, "git", execCmd.Name) assert.Equal(t, 4, len(execCmd.Args)) } func Test_String(t *testing.T) { c := Cmd{ Name: "echo", Args: []string{"hi", "hello world", "don't", `"fake news"`}, } assert.Equal(t, `echo hi "hello world" "don't" '"fake news"'`, c.String()) } ================================================ FILE: commands/alias.go ================================================ package commands import ( "fmt" "os" "path/filepath" "regexp" "strings" "github.com/github/hub/v2/ui" "github.com/github/hub/v2/utils" ) var cmdAlias = &Command{ Run: alias, Usage: "alias [-s] []", Long: `Show shell instructions for wrapping git. ## Options -s Output shell script suitable for ''eval''. Specify the type of shell (default: "$SHELL" environment variable). ## See also: hub(1) `, } func init() { CmdRunner.Use(cmdAlias) } func alias(command *Command, args *Args) { var shell string if args.ParamsSize() > 0 { shell = args.FirstParam() } else { shell = os.Getenv("SHELL") } flagAliasScript := args.Flag.Bool("-s") if shell == "" { cmd := "hub alias " if flagAliasScript { cmd = "hub alias -s " } utils.Check(fmt.Errorf("Error: couldn't detect shell type. Please specify your shell with `%s`", cmd)) } shells := []string{"bash", "zsh", "sh", "ksh", "csh", "tcsh", "fish", "rc"} shell = filepath.Base(shell) var validShell bool for _, s := range shells { if s == shell { validShell = true break } } if !validShell { err := fmt.Errorf("hub alias: unsupported shell\nsupported shells: %s", strings.Join(shells, " ")) utils.Check(err) } if flagAliasScript { var alias string switch shell { case "csh", "tcsh": alias = "alias git hub" case "rc": alias = "fn git { builtin hub $* }" default: alias = "alias git=hub" } ui.Println(alias) } else { var profile string switch shell { case "bash": profile = "~/.bash_profile" case "zsh": profile = "~/.zshrc" case "ksh": profile = "~/.profile" case "fish": profile = "~/.config/fish/functions/git.fish" case "csh": profile = "~/.cshrc" case "tcsh": profile = "~/.tcshrc" case "rc": profile = "$home/lib/profile" default: profile = "your profile" } msg := fmt.Sprintf("# Wrap git automatically by adding the following to %s:\n", profile) ui.Println(msg) var eval string switch shell { case "fish": eval = `function git --wraps hub --description 'Alias for hub, which wraps git to provide extra functionality with GitHub.' hub $argv end` case "rc": eval = "eval `{hub alias -s}" case "csh", "tcsh": eval = "eval \"`hub alias -s`\"" default: eval = `eval "$(hub alias -s)"` } indent := regexp.MustCompile(`(?m)^\t+`) eval = indent.ReplaceAllStringFunc(eval, func(match string) string { return strings.Repeat(" ", len(match)*4) }) ui.Println(eval) } args.NoForward() } ================================================ FILE: commands/api.go ================================================ package commands import ( "bytes" "fmt" "io" "io/ioutil" "os" "regexp" "strconv" "strings" "time" "github.com/github/hub/v2/github" "github.com/github/hub/v2/ui" "github.com/github/hub/v2/utils" ) var cmdAPI = &Command{ Run: apiCommand, Usage: "api [-it] [-X ] [-H
] [--cache ] [-F |--input ]", Long: `Low-level GitHub API request interface. ## Options: -X, --method The HTTP method to use for the request (default: "GET"). The method is automatically set to "POST" if ''--field'', ''--raw-field'', or ''--input'' are used. Use ''-XGET'' to force serializing fields into the query string for the GET request instead of JSON body of the POST request. -F, --field = Data to serialize with the request. has some magic handling; use ''--raw-field'' for sending arbitrary string values. If starts with "@", the rest of the value is interpreted as a filename to read the value from. Use "@-" to read from standard input. If is "true", "false", "null", or looks like a number, an appropriate JSON type is used instead of a string. It is not possible to serialize as a nested JSON array or hash. Instead, construct the request payload externally and pass it via ''--input''. Unless ''-XGET'' was used, all fields are sent serialized as JSON within the request body. When is "graphql", all fields other than "query" are grouped under "variables". See -f, --raw-field = Same as ''--field'', except that it allows values starting with "@", literal strings "true", "false", and "null", as well as strings that look like numbers. --input The filename to read the raw request body from. Use "-" to read from standard input. Use this when you want to manually construct the request payload. -H, --header : Set an HTTP request header. -i, --include Include HTTP response headers in the output. -t, --flat Parse response JSON and output the data in a line-based key-value format suitable for use in shell scripts. --paginate Automatically request and output the next page of results until all resources have been listed. For GET requests, this follows the '''' resource as indicated in the "Link" response header. For GraphQL queries, this utilizes ''pageInfo'' that must be present in the query; see EXAMPLES. Note that multiple JSON documents will be output as a result. If the API rate limit has been reached, the final document that is output will be the HTTP 403 notice, and the process will exit with a non-zero status. One way this can be avoided is by enabling ''--obey-ratelimit''. --color[=] Enable colored output even if stdout is not a terminal. can be one of "always" (default for ''--color''), "never", or "auto" (default). --cache Cache valid responses to GET requests for seconds. When using "graphql" as , caching will apply to responses to POST requests as well. Just make sure to not use ''--cache'' for any GraphQL mutations. --obey-ratelimit After exceeding the API rate limit, pause the process until the reset time of the current rate limit window and retry the request. Note that this may cause the process to hang for a long time (maximum of 1 hour). The GitHub API endpoint to send the HTTP request to (default: "/"). To learn about available endpoints, see . To make GraphQL queries, use "graphql" as and pass ''-F query=QUERY''. If the literal strings "{owner}" or "{repo}" appear in or in the GraphQL "query" field, fill in those placeholders with values read from the git remote configuration of the current git repository. ## Examples: # fetch information about the currently authenticated user as JSON $ hub api user # list user repositories as line-based output $ hub api --flat users/octocat/repos # post a comment to issue #23 of the current repository $ hub api repos/{owner}/{repo}/issues/23/comments --raw-field 'body=Nice job!' # perform a GraphQL query read from a file $ hub api graphql -F query=@path/to/myquery.graphql # perform pagination with GraphQL $ hub api --paginate graphql -f query=' query($endCursor: String) { repositoryOwner(login: "USER") { repositories(first: 100, after: $endCursor) { nodes { nameWithOwner } pageInfo { hasNextPage endCursor } } } } ' ## See also: hub(1) `, } func init() { CmdRunner.Use(cmdAPI) } func apiCommand(_ *Command, args *Args) { path := "" if !args.IsParamsEmpty() { path = args.GetParam(0) } method := "GET" if args.Flag.HasReceived("--method") { method = args.Flag.Value("--method") } else if args.Flag.HasReceived("--field") || args.Flag.HasReceived("--raw-field") || args.Flag.HasReceived("--input") { method = "POST" } cacheTTL := args.Flag.Int("--cache") params := make(map[string]interface{}) for _, val := range args.Flag.AllValues("--field") { parts := strings.SplitN(val, "=", 2) if len(parts) >= 2 { params[parts[0]] = magicValue(parts[1]) } } for _, val := range args.Flag.AllValues("--raw-field") { parts := strings.SplitN(val, "=", 2) if len(parts) >= 2 { params[parts[0]] = parts[1] } } headers := make(map[string]string) for _, val := range args.Flag.AllValues("--header") { parts := strings.SplitN(val, ":", 2) if len(parts) >= 2 { headers[parts[0]] = strings.TrimLeft(parts[1], " ") } } host := "" owner := "" repo := "" localRepo, localRepoErr := github.LocalRepo() if localRepoErr == nil { var project *github.Project if project, localRepoErr = localRepo.MainProject(); localRepoErr == nil { host = project.Host owner = project.Owner repo = project.Name } } if host == "" { defHost, err := github.CurrentConfig().DefaultHostNoPrompt() utils.Check(err) host = defHost.Host } isGraphQL := path == "graphql" if isGraphQL && params["query"] != nil { query := params["query"].(string) query = strings.Replace(query, "{owner}", owner, -1) query = strings.Replace(query, "{repo}", repo, -1) variables := make(map[string]interface{}) for key, value := range params { if key != "query" { variables[key] = value } } if len(variables) > 0 { params = make(map[string]interface{}) params["variables"] = variables } params["query"] = query } else { path = strings.Replace(path, "{owner}", owner, -1) path = strings.Replace(path, "{repo}", repo, -1) } var body interface{} if args.Flag.HasReceived("--input") { fn := args.Flag.Value("--input") if fn == "-" { body = os.Stdin } else { fi, err := os.Open(fn) utils.Check(err) body = fi defer fi.Close() } } else { body = params } gh := github.NewClient(host) out := ui.Stdout colorize := colorizeOutput(args.Flag.HasReceived("--color"), args.Flag.Value("--color")) parseJSON := args.Flag.Bool("--flat") includeHeaders := args.Flag.Bool("--include") paginate := args.Flag.Bool("--paginate") rateLimitWait := args.Flag.Bool("--obey-ratelimit") args.NoForward() for { response, err := gh.GenericAPIRequest(method, path, body, headers, cacheTTL) utils.Check(err) if rateLimitWait && response.StatusCode == 403 && response.RateLimitRemaining() == 0 { pauseUntil(response.RateLimitReset()) continue } success := response.StatusCode < 300 jsonType := true if !success { jsonType, _ = regexp.MatchString(`[/+]json(?:;|$)`, response.Header.Get("Content-Type")) } if includeHeaders { fmt.Fprintf(out, "%s %s\r\n", response.Proto, response.Status) response.Header.Write(out) fmt.Fprintf(out, "\r\n") } endCursor := "" hasNextPage := false if parseJSON && jsonType { hasNextPage, endCursor = utils.JSONPath(out, response.Body, colorize) } else if paginate && isGraphQL { bodyCopy := &bytes.Buffer{} io.Copy(out, io.TeeReader(response.Body, bodyCopy)) hasNextPage, endCursor = utils.JSONPath(ioutil.Discard, bodyCopy, false) } else { io.Copy(out, response.Body) } response.Body.Close() if !success { if ssoErr := github.ValidateGitHubSSO(response.Response); ssoErr != nil { ui.Errorln() ui.Errorln(ssoErr) } if scopeErr := github.ValidateSufficientOAuthScopes(response.Response); scopeErr != nil { ui.Errorln() ui.Errorln(scopeErr) } os.Exit(22) } if paginate { if isGraphQL && hasNextPage && endCursor != "" { if v, ok := params["variables"]; ok { variables := v.(map[string]interface{}) variables["endCursor"] = endCursor } else { variables := map[string]interface{}{"endCursor": endCursor} params["variables"] = variables } goto next } else if nextLink := response.Link("next"); nextLink != "" { path = nextLink goto next } } break next: if !parseJSON { fmt.Fprintf(out, "\n") } if rateLimitWait && response.RateLimitRemaining() == 0 { pauseUntil(response.RateLimitReset()) } } } func pauseUntil(timestamp int) { rollover := time.Unix(int64(timestamp)+1, 0) duration := time.Until(rollover) if duration > 0 { ui.Errorf("API rate limit exceeded; pausing until %v ...\n", rollover) time.Sleep(duration) } } const ( trueVal = "true" falseVal = "false" nilVal = "null" ) func magicValue(value string) interface{} { switch value { case trueVal: return true case falseVal: return false case nilVal: return nil default: if strings.HasPrefix(value, "@") { return string(readFile(value[1:])) } else if i, err := strconv.Atoi(value); err == nil { return i } else { return value } } } func readFile(file string) (content []byte) { var err error if file == "-" { content, err = ioutil.ReadAll(os.Stdin) } else { content, err = ioutil.ReadFile(file) } utils.Check(err) return } ================================================ FILE: commands/apply.go ================================================ package commands import ( "io" "io/ioutil" "os" "regexp" "github.com/github/hub/v2/github" "github.com/github/hub/v2/utils" ) var cmdApply = &Command{ Run: apply, GitExtension: true, Usage: "apply ", Long: `Download a patch from GitHub and apply it locally. ## Options: A URL to a pull request or commit on GitHub. ## Examples: $ hub apply https://github.com/jingweno/gh/pull/55 > curl https://github.com/jingweno/gh/pull/55.patch -o /tmp/55.patch > git apply /tmp/55.patch ## See also: hub-am(1), hub(1), git-apply(1) `, } var cmdAm = &Command{ Run: apply, GitExtension: true, Usage: "am [-3] ", Long: `Replicate commits from a GitHub pull request locally. ## Options: -3 (Recommended) See git-am(1). A URL to a pull request or commit on GitHub. ## Examples: $ hub am -3 https://github.com/jingweno/gh/pull/55 > curl https://github.com/jingweno/gh/pull/55.patch -o /tmp/55.patch > git am -3 /tmp/55.patch ## See also: hub-apply(1), hub-cherry-pick(1), hub(1), git-am(1) `, } func init() { CmdRunner.Use(cmdApply) CmdRunner.Use(cmdAm) } func apply(command *Command, args *Args) { if !args.IsParamsEmpty() { transformApplyArgs(args) } } func transformApplyArgs(args *Args) { gistRegexp := regexp.MustCompile("^https?://gist\\.github\\.com/([\\w.-]+/)?([a-f0-9]+)") commitRegexp := regexp.MustCompile("^(commit|pull/[0-9]+/commits)/([0-9a-f]+)") pullRegexp := regexp.MustCompile("^pull/([0-9]+)") for idx, arg := range args.Params { var ( patch io.ReadCloser apiError error ) projectURL, err := github.ParseURL(arg) if err == nil { gh := github.NewClient(projectURL.Project.Host) if match := commitRegexp.FindStringSubmatch(projectURL.ProjectPath()); match != nil { patch, apiError = gh.CommitPatch(projectURL.Project, match[2]) } else if match := pullRegexp.FindStringSubmatch(projectURL.ProjectPath()); match != nil { patch, apiError = gh.PullRequestPatch(projectURL.Project, match[1]) } } else { match := gistRegexp.FindStringSubmatch(arg) if match != nil { // TODO: support Enterprise gist gh := github.NewClient(github.GitHubHost) patch, apiError = gh.GistPatch(match[2]) } } utils.Check(apiError) if patch == nil { continue } tempDir := os.TempDir() err = os.MkdirAll(tempDir, 0775) utils.Check(err) patchFile, err := ioutil.TempFile(tempDir, "hub") utils.Check(err) _, err = io.Copy(patchFile, patch) utils.Check(err) patchFile.Close() patch.Close() args.ReplaceParam(idx, patchFile.Name()) } } ================================================ FILE: commands/args.go ================================================ package commands import ( "fmt" "strings" "github.com/github/hub/v2/cmd" "github.com/github/hub/v2/utils" ) type Args struct { Executable string GlobalFlags []string Command string ProgramPath string Params []string beforeChain []*cmd.Cmd afterChain []*cmd.Cmd Noop bool Terminator bool noForward bool Callbacks []func() error Flag *utils.ArgsParser } func (a *Args) Words() []string { aa := make([]string, 0) for _, p := range a.Params { if !looksLikeFlag(p) { aa = append(aa, p) } } return aa } func (a *Args) Before(command ...string) { a.beforeChain = append(a.beforeChain, cmd.NewWithArray(command)) } func (a *Args) After(command ...string) { a.afterChain = append(a.afterChain, cmd.NewWithArray(command)) } func (a *Args) AfterFn(fn func() error) { a.Callbacks = append(a.Callbacks, fn) } func (a *Args) NoForward() { a.noForward = true } func (a *Args) Replace(executable, command string, params ...string) { a.Executable = executable a.Command = command a.Params = params a.GlobalFlags = []string{} a.noForward = false } func (a *Args) Commands() []*cmd.Cmd { result := []*cmd.Cmd{} appendFromChain := func(c *cmd.Cmd) { if c.Name == "git" { ga := []string{c.Name} ga = append(ga, a.GlobalFlags...) ga = append(ga, c.Args...) result = append(result, cmd.NewWithArray(ga)) } else { result = append(result, c) } } for _, c := range a.beforeChain { appendFromChain(c) } if !a.noForward { result = append(result, a.ToCmd()) } for _, c := range a.afterChain { appendFromChain(c) } return result } func (a *Args) ToCmd() *cmd.Cmd { c := cmd.NewWithArray(append([]string{a.Executable}, a.GlobalFlags...)) if a.Command != "" { c.WithArg(a.Command) } for _, arg := range a.Params { c.WithArg(arg) } return c } func (a *Args) GetParam(i int) string { return a.Params[i] } func (a *Args) FirstParam() string { if a.ParamsSize() == 0 { panic("Index 0 is out of bound") } return a.Params[0] } func (a *Args) LastParam() string { if a.ParamsSize()-1 < 0 { panic(fmt.Sprintf("Index %d is out of bound", a.ParamsSize()-1)) } return a.Params[a.ParamsSize()-1] } func (a *Args) HasSubcommand() bool { return !a.IsParamsEmpty() && a.Params[0][0] != '-' } func (a *Args) InsertParam(i int, items ...string) { if i < 0 { panic(fmt.Sprintf("Index %d is out of bound", i)) } if i > a.ParamsSize() { i = a.ParamsSize() } newParams := make([]string, 0) newParams = append(newParams, a.Params[:i]...) newParams = append(newParams, items...) newParams = append(newParams, a.Params[i:]...) a.Params = newParams } func (a *Args) RemoveParam(i int) string { item := a.Params[i] a.Params = append(a.Params[:i], a.Params[i+1:]...) return item } func (a *Args) ReplaceParam(i int, item string) { if i < 0 || i > a.ParamsSize()-1 { panic(fmt.Sprintf("Index %d is out of bound", i)) } a.Params[i] = item } func (a *Args) IndexOfParam(param string) int { for i, p := range a.Params { if p == param { return i } } return -1 } func (a *Args) ParamsSize() int { return len(a.Params) } func (a *Args) IsParamsEmpty() bool { return a.ParamsSize() == 0 } func (a *Args) PrependParams(params ...string) { a.Params = append(params, a.Params...) } func (a *Args) AppendParams(params ...string) { a.Params = append(a.Params, params...) } func NewArgs(args []string) *Args { var ( command string params []string noop bool ) cmdIdx := findCommandIndex(args) globalFlags := args[:cmdIdx] if cmdIdx > 0 { args = args[cmdIdx:] for i := len(globalFlags) - 1; i >= 0; i-- { if globalFlags[i] == noopFlag { noop = true globalFlags = append(globalFlags[:i], globalFlags[i+1:]...) } } } if len(args) != 0 { command = args[0] params = args[1:] } return &Args{ Executable: "git", GlobalFlags: globalFlags, Command: command, Params: params, Noop: noop, beforeChain: make([]*cmd.Cmd, 0), afterChain: make([]*cmd.Cmd, 0), } } const ( noopFlag = "--noop" versionFlag = "--version" listCmds = "--list-cmds=" helpFlag = "--help" configFlag = "-c" chdirFlag = "-C" flagPrefix = "-" ) func looksLikeFlag(value string) bool { return strings.HasPrefix(value, flagPrefix) } func findCommandIndex(args []string) int { slurpNextValue := false commandIndex := 0 for i, arg := range args { if slurpNextValue { commandIndex = i + 1 slurpNextValue = false } else if arg == versionFlag || arg == helpFlag || strings.HasPrefix(arg, listCmds) || !looksLikeFlag(arg) { break } else { commandIndex = i + 1 if arg == configFlag || arg == chdirFlag { slurpNextValue = true } } } return commandIndex } ================================================ FILE: commands/args_test.go ================================================ package commands import ( "testing" "github.com/github/hub/v2/internal/assert" ) func TestNewArgs(t *testing.T) { args := NewArgs([]string{}) assert.Equal(t, "", args.Command) assert.Equal(t, 0, args.ParamsSize()) args = NewArgs([]string{"command"}) assert.Equal(t, "command", args.Command) assert.Equal(t, 0, args.ParamsSize()) args = NewArgs([]string{"command", "args"}) assert.Equal(t, "command", args.Command) assert.Equal(t, 1, args.ParamsSize()) args = NewArgs([]string{"--version"}) assert.Equal(t, "--version", args.Command) assert.Equal(t, 0, args.ParamsSize()) args = NewArgs([]string{"--help"}) assert.Equal(t, "--help", args.Command) assert.Equal(t, 0, args.ParamsSize()) } func TestArgs_Words(t *testing.T) { args := NewArgs([]string{"merge", "--no-ff", "master", "-m", "message"}) a := args.Words() assert.Equal(t, 2, len(a)) assert.Equal(t, "master", a[0]) assert.Equal(t, "message", a[1]) } func TestArgs_Insert(t *testing.T) { args := NewArgs([]string{"command", "1", "2", "3", "4"}) args.InsertParam(0, "foo") assert.Equal(t, 5, args.ParamsSize()) assert.Equal(t, "foo", args.FirstParam()) args = NewArgs([]string{"command", "1", "2", "3", "4"}) args.InsertParam(3, "foo") assert.Equal(t, 5, args.ParamsSize()) assert.Equal(t, "foo", args.Params[3]) args = NewArgs([]string{"checkout", "-b"}) args.InsertParam(1, "foo") assert.Equal(t, 2, args.ParamsSize()) assert.Equal(t, "-b", args.Params[0]) assert.Equal(t, "foo", args.Params[1]) args = NewArgs([]string{"checkout"}) args.InsertParam(1, "foo") assert.Equal(t, 1, args.ParamsSize()) assert.Equal(t, "foo", args.Params[0]) } func TestArgs_Remove(t *testing.T) { args := NewArgs([]string{"1", "2", "3", "4"}) item := args.RemoveParam(1) assert.Equal(t, "3", item) assert.Equal(t, 2, args.ParamsSize()) assert.Equal(t, "2", args.FirstParam()) assert.Equal(t, "4", args.GetParam(1)) } func TestArgs_GlobalFlags(t *testing.T) { args := NewArgs([]string{"-c", "key=value", "status", "-s", "-b"}) assert.Equal(t, "status", args.Command) assert.Equal(t, []string{"-c", "key=value"}, args.GlobalFlags) assert.Equal(t, []string{"-s", "-b"}, args.Params) assert.Equal(t, false, args.Noop) } func TestArgs_GlobalFlags_Noop(t *testing.T) { args := NewArgs([]string{"-c", "key=value", "--noop", "--literal-pathspecs", "status", "-s", "-b"}) assert.Equal(t, "status", args.Command) assert.Equal(t, []string{"-c", "key=value", "--literal-pathspecs"}, args.GlobalFlags) assert.Equal(t, []string{"-s", "-b"}, args.Params) assert.Equal(t, true, args.Noop) } func TestArgs_GlobalFlags_NoopTwice(t *testing.T) { args := NewArgs([]string{"--noop", "--bare", "--noop", "status"}) assert.Equal(t, "status", args.Command) assert.Equal(t, []string{"--bare"}, args.GlobalFlags) assert.Equal(t, 0, len(args.Params)) assert.Equal(t, true, args.Noop) } func TestArgs_GlobalFlags_Repeated(t *testing.T) { args := NewArgs([]string{"-C", "mydir", "-c", "a=b", "--bare", "-c", "c=d", "-c", "e=f", "status"}) assert.Equal(t, "status", args.Command) assert.Equal(t, []string{"-C", "mydir", "-c", "a=b", "--bare", "-c", "c=d", "-c", "e=f"}, args.GlobalFlags) assert.Equal(t, 0, len(args.Params)) assert.Equal(t, false, args.Noop) } func TestArgs_GlobalFlags_Propagate(t *testing.T) { args := NewArgs([]string{"-c", "key=value", "status"}) cmd := args.ToCmd() assert.Equal(t, []string{"-c", "key=value", "status"}, cmd.Args) } func TestArgs_GlobalFlags_Replaced(t *testing.T) { args := NewArgs([]string{"-c", "key=value", "status"}) args.Replace("open", "", "-a", "http://example.com") cmd := args.ToCmd() assert.Equal(t, "open", cmd.Name) assert.Equal(t, []string{"-a", "http://example.com"}, cmd.Args) } func TestArgs_ToCmd(t *testing.T) { args := NewArgs([]string{"a", "", "b", ""}) cmd := args.ToCmd() assert.Equal(t, []string{"a", "", "b", ""}, cmd.Args) } func TestArgs_GlobalFlags_BeforeAfterChain(t *testing.T) { args := NewArgs([]string{"-c", "key=value", "-C", "dir", "status"}) args.Before("git", "remote", "add") args.After("git", "clean") args.After("echo", "done!") cmds := args.Commands() assert.Equal(t, 4, len(cmds)) assert.Equal(t, "git -c key=value -C dir remote add", cmds[0].String()) assert.Equal(t, "git -c key=value -C dir status", cmds[1].String()) assert.Equal(t, "git -c key=value -C dir clean", cmds[2].String()) assert.Equal(t, "echo done!", cmds[3].String()) } ================================================ FILE: commands/browse.go ================================================ package commands import ( "fmt" "net/url" "strings" "github.com/github/hub/v2/github" "github.com/github/hub/v2/utils" ) var cmdBrowse = &Command{ Run: browse, Usage: "browse [-uc] [[/]|--] []", Long: `Open a GitHub repository in a web browser. ## Options: -u, --url Print the URL instead of opening it. -c, --copy Put the URL in clipboard instead of opening it. [/] Defaults to repository in the current working directory. One of "wiki", "commits", "issues", or other (default: "tree"). ## Examples: $ hub browse > open https://github.com/REPO $ hub browse -- issues > open https://github.com/REPO/issues $ hub browse jingweno/gh > open https://github.com/jingweno/gh $ hub browse gh wiki > open https://github.com/USER/gh/wiki ## See also: hub-compare(1), hub(1) `, } func init() { CmdRunner.Use(cmdBrowse) } func browse(command *Command, args *Args) { var ( dest string subpage string path string project *github.Project branch *github.Branch err error ) if !args.IsParamsEmpty() { dest = args.RemoveParam(0) } if !args.IsParamsEmpty() { subpage = args.RemoveParam(0) } if args.Terminator { subpage = dest dest = "" } localRepo, _ := github.LocalRepo() if dest != "" { project = github.NewProject("", dest, "") branch = localRepo.MasterBranch() } else if subpage != "" && subpage != "commits" && subpage != "tree" && subpage != "blob" && subpage != "settings" { project, err = localRepo.MainProject() branch = localRepo.MasterBranch() utils.Check(err) } else { currentBranch, err := localRepo.CurrentBranch() if err != nil { currentBranch = localRepo.MasterBranch() } var owner string mainProject, err := localRepo.MainProject() if err == nil { host, err := github.CurrentConfig().PromptForHost(mainProject.Host) if err != nil { utils.Check(github.FormatError("in browse", err)) } else { owner = host.User } } branch, project, _ = localRepo.RemoteBranchAndProject(owner, currentBranch.IsMaster()) if branch == nil { branch = localRepo.MasterBranch() } } if project == nil { utils.Check(command.UsageError("")) } if subpage == "commits" { path = fmt.Sprintf("commits/%s", branchInURL(branch)) } else if subpage == "tree" || subpage == "" { if !branch.IsMaster() { path = fmt.Sprintf("tree/%s", branchInURL(branch)) } } else { path = subpage } pageURL := project.WebURL("", "", path) args.NoForward() flagBrowseURLPrint := args.Flag.Bool("--url") flagBrowseURLCopy := args.Flag.Bool("--copy") printBrowseOrCopy(args, pageURL, !flagBrowseURLPrint && !flagBrowseURLCopy, flagBrowseURLCopy) } func branchInURL(branch *github.Branch) string { parts := strings.Split(branch.ShortName(), "/") newPath := make([]string, len(parts)) for i, s := range parts { newPath[i] = url.QueryEscape(s) } return strings.Join(newPath, "/") } ================================================ FILE: commands/checkout.go ================================================ package commands import ( "fmt" "regexp" "github.com/github/hub/v2/git" "github.com/github/hub/v2/github" "github.com/github/hub/v2/utils" ) var cmdCheckout = &Command{ Run: checkout, GitExtension: true, Usage: "checkout []", Long: `Check out the head of a pull request as a local branch. ## Examples: $ hub checkout https://github.com/jingweno/gh/pull/73 > git fetch origin pull/73/head:jingweno-feature > git checkout jingweno-feature ## See also: hub-merge(1), hub-am(1), hub(1), git-checkout(1) `, } func init() { CmdRunner.Use(cmdCheckout) } func checkout(command *Command, args *Args) { words := args.Words() if len(words) == 0 { return } checkoutURL := words[0] var newBranchName string if len(words) > 1 { newBranchName = words[1] } url, err := github.ParseURL(checkoutURL) if err != nil { // not a valid GitHub URL return } pullURLRegex := regexp.MustCompile("^pull/(\\d+)") projectPath := url.ProjectPath() if !pullURLRegex.MatchString(projectPath) { // not a valid PR URL return } err = sanitizeCheckoutFlags(args) utils.Check(err) id := pullURLRegex.FindStringSubmatch(projectPath)[1] gh := github.NewClient(url.Project.Host) pullRequest, err := gh.PullRequest(url.Project, id) utils.Check(err) newArgs, err := transformCheckoutArgs(args, pullRequest, newBranchName) utils.Check(err) if idx := args.IndexOfParam(newBranchName); idx >= 0 { args.RemoveParam(idx) } replaceCheckoutParam(args, checkoutURL, newArgs...) } func transformCheckoutArgs(args *Args, pullRequest *github.PullRequest, newBranchName string) (newArgs []string, err error) { repo, err := github.LocalRepo() if err != nil { return } baseRemote, err := repo.RemoteForRepo(pullRequest.Base.Repo) if err != nil { return } var headRemote *github.Remote if pullRequest.IsSameRepo() { headRemote = baseRemote } else if pullRequest.Head.Repo != nil { headRemote, _ = repo.RemoteForRepo(pullRequest.Head.Repo) } if headRemote != nil { if newBranchName == "" { newBranchName = pullRequest.Head.Ref } remoteBranch := fmt.Sprintf("%s/%s", headRemote.Name, pullRequest.Head.Ref) refSpec := fmt.Sprintf("+refs/heads/%s:refs/remotes/%s", pullRequest.Head.Ref, remoteBranch) if git.HasFile("refs", "heads", newBranchName) { newArgs = append(newArgs, newBranchName) args.After("git", "merge", "--ff-only", fmt.Sprintf("refs/remotes/%s", remoteBranch)) } else { newArgs = append(newArgs, "-b", newBranchName, "--no-track", remoteBranch) args.After("git", "config", fmt.Sprintf("branch.%s.remote", newBranchName), headRemote.Name) args.After("git", "config", fmt.Sprintf("branch.%s.merge", newBranchName), "refs/heads/"+pullRequest.Head.Ref) } args.Before("git", "fetch", headRemote.Name, refSpec) } else { if newBranchName == "" { newBranchName = pullRequest.Head.Ref if pullRequest.Head.Repo != nil && newBranchName == pullRequest.Head.Repo.DefaultBranch { newBranchName = fmt.Sprintf("%s-%s", pullRequest.Head.Repo.Owner.Login, newBranchName) } } newArgs = append(newArgs, newBranchName) b, errB := repo.CurrentBranch() isCurrentBranch := errB == nil && b.ShortName() == newBranchName ref := fmt.Sprintf("refs/pull/%d/head", pullRequest.Number) if isCurrentBranch { args.Before("git", "fetch", baseRemote.Name, ref) args.After("git", "merge", "--ff-only", "FETCH_HEAD") } else { args.Before("git", "fetch", baseRemote.Name, fmt.Sprintf("%s:%s", ref, newBranchName)) } remote := baseRemote.Name mergeRef := ref if pullRequest.MaintainerCanModify && pullRequest.Head.Repo != nil { var project *github.Project project, err = github.NewProjectFromRepo(pullRequest.Head.Repo) if err != nil { return } remote = project.GitURL("", "", true) mergeRef = fmt.Sprintf("refs/heads/%s", pullRequest.Head.Ref) } if mc, err := git.Config(fmt.Sprintf("branch.%s.merge", newBranchName)); err != nil || mc == "" { args.After("git", "config", fmt.Sprintf("branch.%s.remote", newBranchName), remote) args.After("git", "config", fmt.Sprintf("branch.%s.merge", newBranchName), mergeRef) } } return } func sanitizeCheckoutFlags(args *Args) error { if i := args.IndexOfParam("-b"); i != -1 { return fmt.Errorf("Unsupported flag -b when checking out pull request") } if i := args.IndexOfParam("--orphan"); i != -1 { return fmt.Errorf("Unsupported flag --orphan when checking out pull request") } return nil } func replaceCheckoutParam(args *Args, checkoutURL string, replacement ...string) { idx := args.IndexOfParam(checkoutURL) args.RemoveParam(idx) args.InsertParam(idx, replacement...) } ================================================ FILE: commands/cherry_pick.go ================================================ package commands import ( "fmt" "regexp" "github.com/github/hub/v2/github" "github.com/github/hub/v2/utils" ) var cmdCherryPick = &Command{ Run: cherryPick, GitExtension: true, Usage: ` cherry-pick cherry-pick @ `, Long: `Cherry-pick a commit from a fork on GitHub. ## See also: hub-am(1), hub(1), git-cherry-pick(1) `, } func init() { CmdRunner.Use(cmdCherryPick) } func cherryPick(command *Command, args *Args) { if args.IndexOfParam("-m") == -1 && args.IndexOfParam("--mainline") == -1 { transformCherryPickArgs(args) } } func transformCherryPickArgs(args *Args) { if args.IsParamsEmpty() { return } var project *github.Project var sha, refspec string shaRe := "[a-f0-9]{7,40}" var mainProject *github.Project localRepo, mainProjectErr := github.LocalRepo() if mainProjectErr == nil { mainProject, mainProjectErr = localRepo.MainProject() } ref := args.LastParam() if url, err := github.ParseURL(ref); err == nil { projectPath := url.ProjectPath() commitRegex := regexp.MustCompile(fmt.Sprintf("^commit/(%s)", shaRe)) pullRegex := regexp.MustCompile(fmt.Sprintf(`^pull/(\d+)/commits/(%s)`, shaRe)) if matches := commitRegex.FindStringSubmatch(projectPath); len(matches) > 0 { sha = matches[1] project = url.Project } else if matches := pullRegex.FindStringSubmatch(projectPath); len(matches) > 0 { pullID := matches[1] sha = matches[2] utils.Check(mainProjectErr) project = mainProject refspec = fmt.Sprintf("refs/pull/%s/head", pullID) } } else { ownerWithShaRegexp := regexp.MustCompile(fmt.Sprintf("^(%s)@(%s)$", OwnerRe, shaRe)) if matches := ownerWithShaRegexp.FindStringSubmatch(ref); len(matches) > 0 { utils.Check(mainProjectErr) project = mainProject project.Owner = matches[1] sha = matches[2] } } if project != nil { args.ReplaceParam(args.IndexOfParam(ref), sha) tmpName := "_hub-cherry-pick" remoteName := tmpName if remote, err := localRepo.RemoteForProject(project); err == nil { remoteName = remote.Name } else { args.Before("git", "remote", "add", remoteName, project.GitURL("", "", false)) } fetchArgs := []string{"git", "fetch", "-q", "--no-tags", remoteName} if refspec != "" { fetchArgs = append(fetchArgs, refspec) } args.Before(fetchArgs...) if remoteName == tmpName { args.Before("git", "remote", "rm", remoteName) } } } ================================================ FILE: commands/ci_status.go ================================================ package commands import ( "fmt" "os" "sort" "github.com/github/hub/v2/git" "github.com/github/hub/v2/github" "github.com/github/hub/v2/ui" "github.com/github/hub/v2/utils" ) var cmdCiStatus = &Command{ Run: ciStatus, Usage: "ci-status [-v] []", Long: `Display status of GitHub checks for a commit. ## Options: -v, --verbose Print detailed report of all status checks and their URLs. -f, --format Pretty print all status checks using (implies ''--verbose''). See the "PRETTY FORMATS" section of git-log(1) for some additional details on how placeholders are used in format. The available placeholders for checks are: %U: the URL of this status check %S: check state (e.g. "success", "failure") %sC: set color to red, green, or yellow, depending on state %t: name of the status check --color[=] Enable colored output even if stdout is not a terminal. can be one of "always" (default for ''--color''), "never", or "auto" (default). A commit SHA or branch name (default: "HEAD"). Possible outputs and exit statuses: - success, neutral: 0 - failure, error, action_required, cancelled, timed_out: 1 - pending: 2 ## See also: hub-pull-request(1), hub(1) `, } var severityList []string func init() { CmdRunner.Use(cmdCiStatus) severityList = []string{ "neutral", "success", "pending", "cancelled", "timed_out", "action_required", "failure", "error", } } func checkSeverity(targetState string) int { for i, state := range severityList { if state == targetState { return i } } return -1 } func ciStatus(cmd *Command, args *Args) { ref := "HEAD" if !args.IsParamsEmpty() { ref = args.RemoveParam(0) } localRepo, err := github.LocalRepo() utils.Check(err) project, err := localRepo.MainProject() utils.Check(err) sha, err := git.Ref(ref) if err != nil { err = fmt.Errorf("Aborted: no revision could be determined from '%s'", ref) } utils.Check(err) if args.Noop { ui.Printf("Would request CI status for %s\n", sha) } else { gh := github.NewClient(project.Host) response, err := gh.FetchCIStatus(project, sha) utils.Check(err) state := "" if len(response.Statuses) > 0 { for _, status := range response.Statuses { if checkSeverity(status.State) > checkSeverity(state) { state = status.State } } } var exitCode int switch state { case "success", "neutral": exitCode = 0 case "failure", "error", "action_required", "cancelled", "timed_out": exitCode = 1 case "pending": exitCode = 2 default: exitCode = 3 } verbose := args.Flag.Bool("--verbose") || args.Flag.HasReceived("--format") if verbose && len(response.Statuses) > 0 { colorize := colorizeOutput(args.Flag.HasReceived("--color"), args.Flag.Value("--color")) ciVerboseFormat(response.Statuses, args.Flag.Value("--format"), colorize) } else { if state != "" { ui.Println(state) } else { ui.Println("no status") } } os.Exit(exitCode) } } func ciVerboseFormat(statuses []github.CIStatus, formatString string, colorize bool) { contextWidth := 0 for _, status := range statuses { if len(status.Context) > contextWidth { contextWidth = len(status.Context) } } sort.SliceStable(statuses, func(a, b int) bool { return stateRank(statuses[a].State) < stateRank(statuses[b].State) }) for _, status := range statuses { var color int var stateMarker string switch status.State { case "success": stateMarker = "✔︎" color = 32 case "failure", "error", "action_required", "cancelled", "timed_out": stateMarker = "✖︎" color = 31 case "neutral": stateMarker = "◦" color = 30 case "pending": stateMarker = "●" color = 33 } placeholders := map[string]string{ "S": status.State, "sC": "", "t": status.Context, "U": status.TargetURL, } if colorize { placeholders["sC"] = fmt.Sprintf("\033[%dm", color) } format := formatString if format == "" { if status.TargetURL == "" { format = fmt.Sprintf("%%sC%s%%Creset\t%%t\n", stateMarker) } else { format = fmt.Sprintf("%%sC%s%%Creset\t%%<(%d)%%t\t%%U\n", stateMarker, contextWidth) } } ui.Print(ui.Expand(format, placeholders, colorize)) } } func stateRank(state string) uint32 { switch state { case "failure", "error", "action_required", "cancelled", "timed_out": return 1 case "success", "neutral": return 3 default: return 2 } } ================================================ FILE: commands/clone.go ================================================ package commands import ( "fmt" "regexp" "strings" "github.com/github/hub/v2/github" "github.com/github/hub/v2/utils" ) var cmdClone = &Command{ Run: clone, GitExtension: true, Usage: "clone [-p] [] [/] []", Long: `Clone a repository from GitHub. ## Options: -p (Deprecated) Clone private repositories over SSH. [/] defaults to your own GitHub username. Directory name to clone into (default: ). ## Protocol used for cloning HTTPS protocol is used by hub as the default. Alternatively, hub can be configured to use SSH protocol for all git operations. See "SSH instead of HTTPS protocol" and "HUB_PROTOCOL" of hub(1). ## Examples: $ hub clone rtomayko/ronn > git clone https://github.com/rtomayko/ronn.git ## See also: hub-fork(1), hub(1), git-clone(1) `, } func init() { CmdRunner.Use(cmdClone) } func clone(command *Command, args *Args) { if !args.IsParamsEmpty() { transformCloneArgs(args) } } func transformCloneArgs(args *Args) { isPrivate := parseClonePrivateFlag(args) // git help clone | grep -e '^ \+-.\+<' p := utils.NewArgsParser() p.RegisterValue("--branch", "-b") p.RegisterValue("--depth") p.RegisterValue("--reference") if args.Command == "submodule" { p.RegisterValue("--name") } else { p.RegisterValue("--config", "-c") p.RegisterValue("--jobs", "-j") p.RegisterValue("--origin", "-o") p.RegisterValue("--reference-if-able") p.RegisterValue("--separate-git-dir") p.RegisterValue("--shallow-exclude") p.RegisterValue("--shallow-since") p.RegisterValue("--template") p.RegisterValue("--upload-pack", "-u") } p.Parse(args.Params) nameWithOwnerRegexp := regexp.MustCompile(NameWithOwnerRe) if len(p.PositionalIndices) > 0 { i := p.PositionalIndices[0] a := args.Params[i] if nameWithOwnerRegexp.MatchString(a) && !isCloneable(a) { url := getCloneURL(a, isPrivate, args.Command != "submodule") args.ReplaceParam(i, url) } } } func parseClonePrivateFlag(args *Args) bool { if i := args.IndexOfParam("-p"); i != -1 { args.RemoveParam(i) return true } return false } func getCloneURL(nameWithOwner string, allowPush, allowPrivate bool) string { name := nameWithOwner owner := "" if strings.Contains(name, "/") { split := strings.SplitN(name, "/", 2) owner = split[0] name = split[1] } var host *github.Host if owner == "" { config := github.CurrentConfig() h, err := config.DefaultHost() if err != nil { utils.Check(github.FormatError("cloning repository", err)) } host = h owner = host.User } var hostStr string if host != nil { hostStr = host.Host } expectWiki := strings.HasSuffix(name, ".wiki") if expectWiki { name = strings.TrimSuffix(name, ".wiki") } project := github.NewProject(owner, name, hostStr) gh := github.NewClient(project.Host) repo, err := gh.Repository(project) if err != nil { if strings.Contains(err.Error(), "HTTP 404") { err = fmt.Errorf("Error: repository %s/%s doesn't exist", project.Owner, project.Name) } utils.Check(err) } owner = repo.Owner.Login name = repo.Name if expectWiki { if !repo.HasWiki { utils.Check(fmt.Errorf("Error: %s/%s doesn't have a wiki", owner, name)) } else { name = name + ".wiki" } } if !allowPush && allowPrivate { allowPush = repo.Private || repo.Permissions.Push } return project.GitURL(name, owner, allowPush) } ================================================ FILE: commands/commands.go ================================================ package commands import ( "fmt" "regexp" "strings" "github.com/github/hub/v2/utils" ) var ( NameRe = `[\w.-]+` OwnerRe = "[a-zA-Z0-9][a-zA-Z0-9-]*" NameWithOwnerRe = fmt.Sprintf(`^(%s/)?%s$`, OwnerRe, NameRe) CmdRunner = NewRunner() ) type Command struct { Run func(cmd *Command, args *Args) Key string Usage string Long string KnownFlags string GitExtension bool subCommands map[string]*Command parentCommand *Command } func (c *Command) Call(args *Args) (err error) { runCommand, err := c.lookupSubCommand(args) if err != nil { return } if !c.GitExtension { err = runCommand.parseArguments(args) if err != nil { return } } runCommand.Run(runCommand, args) return } type ErrHelp struct { err string } func (e ErrHelp) Error() string { return e.err } func (c *Command) parseArguments(args *Args) error { knownFlags := c.KnownFlags if knownFlags == "" { knownFlags = c.Long } args.Flag = utils.NewArgsParserWithUsage("-h, --help\n" + knownFlags) rest, err := args.Flag.Parse(args.Params) if err != nil { return fmt.Errorf("%s\n%s", err, c.Synopsis()) } if args.Flag.Bool("--help") { return &ErrHelp{err: c.Synopsis()} } args.Params = rest args.Terminator = args.Flag.HasTerminated return nil } func (c *Command) Use(subCommand *Command) { if c.subCommands == nil { c.subCommands = make(map[string]*Command) } c.subCommands[subCommand.Name()] = subCommand subCommand.parentCommand = c } func (c *Command) UsageError(msg string) error { nl := "" if msg != "" { nl = "\n" } return fmt.Errorf("%s%s%s", msg, nl, c.Synopsis()) } func (c *Command) Synopsis() string { lines := []string{} usagePrefix := "Usage:" usageStr := c.Usage if usageStr == "" && c.parentCommand != nil { usageStr = c.parentCommand.Usage } for _, line := range strings.Split(usageStr, "\n") { if line != "" { usage := fmt.Sprintf("%s hub %s", usagePrefix, line) usagePrefix = " " lines = append(lines, usage) } } return strings.Join(lines, "\n") } func (c *Command) HelpText() string { usage := strings.Replace(c.Usage, "-^", "`-^`", 1) usageRe := regexp.MustCompile(`(?m)^([a-z-]+)(.*)$`) usage = usageRe.ReplaceAllString(usage, "`hub $1`$2 ") usage = strings.TrimSpace(usage) var desc string long := strings.TrimSpace(c.Long) if lines := strings.Split(long, "\n"); len(lines) > 1 { desc = lines[0] long = strings.Join(lines[1:], "\n") } long = strings.Replace(long, "''", "`", -1) headingRe := regexp.MustCompile(`(?m)^(## .+):$`) long = headingRe.ReplaceAllString(long, "$1") indentRe := regexp.MustCompile(`(?m)^\t`) long = indentRe.ReplaceAllLiteralString(long, "") definitionListRe := regexp.MustCompile(`(?m)^(\* )?([^#\s][^\n]*?):?\n\t`) long = definitionListRe.ReplaceAllString(long, "$2\n:\t") return fmt.Sprintf("hub-%s(1) -- %s\n===\n\n## Synopsis\n\n%s\n%s", c.Name(), desc, usage, long) } func (c *Command) Name() string { if c.Key != "" { return c.Key } usageLine := strings.Split(strings.TrimSpace(c.Usage), "\n")[0] return strings.Split(usageLine, " ")[0] } func (c *Command) Runnable() bool { return c.Run != nil } func (c *Command) lookupSubCommand(args *Args) (runCommand *Command, err error) { if len(c.subCommands) > 0 && args.HasSubcommand() { subCommandName := args.FirstParam() if subCommand, ok := c.subCommands[subCommandName]; ok { runCommand = subCommand args.Params = args.Params[1:] } else { err = fmt.Errorf("error: Unknown subcommand: %s", subCommandName) } } else { runCommand = c } return } ================================================ FILE: commands/commands_test.go ================================================ package commands import ( "io/ioutil" "os" "regexp" "testing" "github.com/github/hub/v2/internal/assert" "github.com/github/hub/v2/ui" ) func TestMain(m *testing.M) { ui.Default = ui.Console{Stdout: ioutil.Discard, Stderr: ioutil.Discard} os.Exit(m.Run()) } func TestCommandUseSelf(t *testing.T) { c := &Command{Usage: "foo"} args := NewArgs([]string{"foo"}) run, err := c.lookupSubCommand(args) assert.Equal(t, nil, err) assert.Equal(t, c, run) } func TestCommandUseSubcommand(t *testing.T) { c := &Command{Usage: "foo"} s := &Command{Usage: "bar"} c.Use(s) args := NewArgs([]string{"foo", "bar"}) run, err := c.lookupSubCommand(args) assert.Equal(t, nil, err) assert.Equal(t, s, run) } func TestCommandUseErrorWhenMissingSubcommand(t *testing.T) { c := &Command{Usage: "foo"} s := &Command{Usage: "bar"} c.Use(s) args := NewArgs([]string{"foo", "baz"}) _, err := c.lookupSubCommand(args) assert.NotEqual(t, nil, err) } func TestArgsForCommand(t *testing.T) { c := &Command{Usage: "foo"} args := NewArgs([]string{"foo", "bar", "baz"}) c.lookupSubCommand(args) assert.Equal(t, 2, len(args.Params)) } func TestArgsForSubCommand(t *testing.T) { c := &Command{Usage: "foo"} s := &Command{Usage: "bar"} c.Use(s) args := NewArgs([]string{"foo", "bar", "baz"}) c.lookupSubCommand(args) assert.Equal(t, 1, len(args.Params)) } func TestFlagsAfterArguments(t *testing.T) { c := &Command{Long: "-m, --message MSG"} args := NewArgs([]string{"foo", "bar", "-m", "baz"}) err := c.parseArguments(args) assert.Equal(t, nil, err) assert.Equal(t, "baz", args.Flag.Value("--message")) assert.Equal(t, 1, len(args.Params)) assert.Equal(t, "bar", args.LastParam()) } func TestCommandNameTakeKey(t *testing.T) { c := &Command{Key: "bar", Usage: "foo -t -v --foo"} assert.Equal(t, "bar", c.Name()) } func TestCommandCall(t *testing.T) { var result string f := func(c *Command, args *Args) { result = args.FirstParam() } c := &Command{Usage: "foo", Run: f} args := NewArgs([]string{"foo", "bar"}) c.Call(args) assert.Equal(t, "bar", result) } func TestCommandHelp(t *testing.T) { var result string f := func(c *Command, args *Args) { result = args.FirstParam() } c := &Command{Usage: "foo", Run: f} args := NewArgs([]string{"foo", "-h"}) c.Call(args) assert.Equal(t, "", result) } func TestSubCommandCall(t *testing.T) { var result string f1 := func(c *Command, args *Args) { result = "noop" } f2 := func(c *Command, args *Args) { result = args.LastParam() } c := &Command{Usage: "foo", Run: f1} s := &Command{Key: "bar", Usage: "foo bar", Run: f2} c.Use(s) args := NewArgs([]string{"foo", "bar", "baz"}) c.Call(args) assert.Equal(t, "baz", result) } func Test_NameWithOwnerRe(t *testing.T) { re := regexp.MustCompile(NameWithOwnerRe) assert.Equal(t, true, re.MatchString("o/n")) assert.Equal(t, true, re.MatchString("own-er/my-project.git")) assert.Equal(t, true, re.MatchString("my-project.git")) assert.Equal(t, true, re.MatchString("my_project")) assert.Equal(t, true, re.MatchString("-dash")) assert.Equal(t, true, re.MatchString(".dotfiles")) assert.Equal(t, false, re.MatchString("")) assert.Equal(t, false, re.MatchString("/")) assert.Equal(t, false, re.MatchString(" ")) assert.Equal(t, false, re.MatchString("owner/na me")) assert.Equal(t, false, re.MatchString("owner/na/me")) assert.Equal(t, false, re.MatchString("own.er/name")) assert.Equal(t, false, re.MatchString("own_er/name")) assert.Equal(t, false, re.MatchString("-owner/name")) } ================================================ FILE: commands/compare.go ================================================ package commands import ( "fmt" "net/url" "regexp" "strings" "github.com/github/hub/v2/github" "github.com/github/hub/v2/utils" ) var cmdCompare = &Command{ Run: compare, Usage: ` compare [-uc] [-b ] compare [-uc] [] [...] `, Long: `Open a GitHub compare page in a web browser. ## Options: -u, --url Print the URL instead of opening it. -c, --copy Put the URL to clipboard instead of opening it. -b, --base Base branch to compare against in case no explicit arguments were given. [...] Branch names, tag names, or commit SHAs specifying the range to compare. If a range with two dots (''A..B'') is given, it will be transformed into a range with three dots. The portion defaults to the default branch of the repository. The argument defaults to the current branch. If the current branch is not pushed to a remote, the command will error. Optionally specify the owner of the repository for the compare page URL. ## Examples: $ hub compare > open https://github.com/OWNER/REPO/compare/BRANCH $ hub compare refactor > open https://github.com/OWNER/REPO/compare/refactor $ hub compare v1.0..v1.1 > open https://github.com/OWNER/REPO/compare/v1.0...v1.1 $ hub compare -u jingweno feature https://github.com/jingweno/REPO/compare/feature ## See also: hub-browse(1), hub(1) `, } func init() { CmdRunner.Use(cmdCompare) } func compare(command *Command, args *Args) { localRepo, err := github.LocalRepo() utils.Check(err) mainProject, err := localRepo.MainProject() utils.Check(err) host, err := github.CurrentConfig().PromptForHost(mainProject.Host) utils.Check(err) var r string flagCompareBase := args.Flag.Value("--base") if args.IsParamsEmpty() { currentBranch, err := localRepo.CurrentBranch() if err != nil { utils.Check(command.UsageError(err.Error())) } var remoteBranch *github.Branch var remoteProject *github.Project remoteBranch, remoteProject, err = findPushTarget(currentBranch) if err != nil { if remoteProject, err = deducePushTarget(currentBranch, host.User); err == nil { remoteBranch = currentBranch } else { utils.Check(fmt.Errorf("the current branch '%s' doesn't seem pushed to a remote", currentBranch.ShortName())) } } r = remoteBranch.ShortName() if remoteProject.SameAs(mainProject) { if flagCompareBase == "" && remoteBranch.IsMaster() { utils.Check(fmt.Errorf("the branch to compare '%s' is the default branch", remoteBranch.ShortName())) } } else { r = fmt.Sprintf("%s:%s", remoteProject.Owner, r) } if flagCompareBase == r { utils.Check(fmt.Errorf("the branch to compare '%s' is the same as --base", r)) } else if flagCompareBase != "" { r = fmt.Sprintf("%s...%s", flagCompareBase, r) } } else { if flagCompareBase != "" { utils.Check(command.UsageError("")) } else { r = parseCompareRange(args.RemoveParam(args.ParamsSize() - 1)) if !args.IsParamsEmpty() { owner := args.RemoveParam(args.ParamsSize() - 1) mainProject = github.NewProject(owner, mainProject.Name, mainProject.Host) } } } url := mainProject.WebURL("", "", "compare/"+rangeQueryEscape(r)) args.NoForward() flagCompareURLOnly := args.Flag.Bool("--url") flagCompareCopy := args.Flag.Bool("--copy") printBrowseOrCopy(args, url, !flagCompareURLOnly && !flagCompareCopy, flagCompareCopy) } func parseCompareRange(r string) string { shaOrTag := fmt.Sprintf("((?:%s:)?\\w(?:[\\w/.-]*\\w)?)", OwnerRe) shaOrTagRange := fmt.Sprintf("^%s\\.\\.%s$", shaOrTag, shaOrTag) shaOrTagRangeRegexp := regexp.MustCompile(shaOrTagRange) return shaOrTagRangeRegexp.ReplaceAllString(r, "$1...$2") } // characters we want to allow unencoded in compare views var compareUnescaper = strings.NewReplacer( "%2F", "/", "%3A", ":", "%5E", "^", "%7E", "~", "%2A", "*", "%21", "!", ) func rangeQueryEscape(r string) string { if strings.Contains(r, "..") { return r } return compareUnescaper.Replace(url.QueryEscape(r)) } ================================================ FILE: commands/compare_test.go ================================================ package commands import ( "github.com/github/hub/v2/internal/assert" "testing" ) func TestParseRange(t *testing.T) { s := "1.0..2.0" assert.Equal(t, "1.0...2.0", parseCompareRange(s)) s = "1.0...2.0" assert.Equal(t, "1.0...2.0", parseCompareRange(s)) } ================================================ FILE: commands/create.go ================================================ package commands import ( "fmt" "strings" "github.com/github/hub/v2/git" "github.com/github/hub/v2/github" "github.com/github/hub/v2/ui" "github.com/github/hub/v2/utils" ) var cmdCreate = &Command{ Run: create, Usage: "create [-poc] [-d ] [-h ] [[/]]", Long: `Create a new repository on GitHub and add a git remote for it. ## Options: -p, --private Create a private repository. -d, --description A short description of the GitHub repository. -h, --homepage A URL with more information about the repository. Use this, for example, if your project has an external website. --remote-name Set the name for the new git remote (default: "origin"). -o, --browse Open the new repository in a web browser. -c, --copy Put the URL of the new repository to clipboard instead of printing it. [/] The name for the repository on GitHub (default: name of the current working directory). Optionally, create the repository within . ## Examples: $ hub create [ repo created on GitHub ] > git remote add -f origin git@github.com:USER/REPO.git $ hub create sinatra/recipes [ repo created in GitHub organization ] > git remote add -f origin git@github.com:sinatra/recipes.git ## See also: hub-init(1), hub(1) `, } func init() { CmdRunner.Use(cmdCreate) } func create(command *Command, args *Args) { _, err := git.Dir() if err != nil { err = fmt.Errorf("'create' must be run from inside a git repository") utils.Check(err) } var newRepoName string if args.IsParamsEmpty() { dirName, err := git.WorkdirName() utils.Check(err) newRepoName = github.SanitizeProjectName(dirName) } else { newRepoName = args.FirstParam() if newRepoName == "" { utils.Check(command.UsageError("")) } } config := github.CurrentConfig() host, err := config.DefaultHost() if err != nil { utils.Check(github.FormatError("creating repository", err)) } owner := host.User if strings.Contains(newRepoName, "/") { split := strings.SplitN(newRepoName, "/", 2) owner = split[0] newRepoName = split[1] } project := github.NewProject(owner, newRepoName, host.Host) gh := github.NewClient(project.Host) flagCreatePrivate := args.Flag.Bool("--private") repo, err := gh.Repository(project) if err == nil { foundProject := github.NewProject(repo.FullName, "", project.Host) if foundProject.SameAs(project) { if !repo.Private && flagCreatePrivate { err = fmt.Errorf("Repository '%s' already exists and is public", repo.FullName) utils.Check(err) } else { ui.Errorln("Existing repository detected") project = foundProject } } else { repo = nil } } else { repo = nil } if repo == nil { if !args.Noop { flagCreateDescription := args.Flag.Value("--description") flagCreateHomepage := args.Flag.Value("--homepage") repo, err := gh.CreateRepository(project, flagCreateDescription, flagCreateHomepage, flagCreatePrivate) utils.Check(err) project = github.NewProject(repo.FullName, "", project.Host) } } localRepo, err := github.LocalRepo() utils.Check(err) originName := args.Flag.Value("--remote-name") if originName == "" { originName = "origin" } if originRemote, err := localRepo.RemoteByName(originName); err == nil { originProject, err := originRemote.Project() if err != nil || !originProject.SameAs(project) { ui.Errorf("A git remote named '%s' already exists and is set to push to '%s'.\n", originRemote.Name, originRemote.PushURL) } } else { url := project.GitURL("", "", true) args.Before("git", "remote", "add", "-f", originName, url) } webURL := project.WebURL("", "", "") args.NoForward() flagCreateBrowse := args.Flag.Bool("--browse") flagCreateCopy := args.Flag.Bool("--copy") printBrowseOrCopy(args, webURL, flagCreateBrowse, flagCreateCopy) } ================================================ FILE: commands/delete.go ================================================ package commands import ( "bufio" "fmt" "os" "regexp" "strings" "github.com/github/hub/v2/github" "github.com/github/hub/v2/ui" "github.com/github/hub/v2/utils" ) var cmdDelete = &Command{ Run: deleteRepo, Usage: "delete [-y] [/]", Long: `Delete an existing repository on GitHub. ## Options: -y, --yes Skip the confirmation prompt and immediately delete the repository. [/] The name for the repository on GitHub. ## Examples: $ hub delete recipes [ personal repo deleted on GitHub ] $ hub delete sinatra/recipes [ repo deleted in GitHub organization ] ## See also: hub-init(1), hub(1) `, } func init() { CmdRunner.Use(cmdDelete) } func deleteRepo(command *Command, args *Args) { var repoName string if !args.IsParamsEmpty() { repoName = args.FirstParam() } re := regexp.MustCompile(NameWithOwnerRe) if !re.MatchString(repoName) { utils.Check(command.UsageError("")) } config := github.CurrentConfig() host, err := config.DefaultHost() if err != nil { utils.Check(github.FormatError("deleting repository", err)) } owner := host.User if strings.Contains(repoName, "/") { split := strings.SplitN(repoName, "/", 2) owner, repoName = split[0], split[1] } project := github.NewProject(owner, repoName, host.Host) gh := github.NewClient(project.Host) if !args.Flag.Bool("--yes") { ui.Printf("Really delete repository '%s' (yes/N)? ", project) answer := "" scanner := bufio.NewScanner(os.Stdin) if scanner.Scan() { answer = strings.TrimSpace(scanner.Text()) } utils.Check(scanner.Err()) if answer != "yes" { utils.Check(fmt.Errorf("Please type 'yes' for confirmation.")) } } if args.Noop { ui.Printf("Would delete repository '%s'.\n", project) } else { err = gh.DeleteRepository(project) if err != nil && strings.Contains(err.Error(), "HTTP 403") { ui.Errorf("Please edit the token used for hub at https://%s/settings/tokens\n", project.Host) ui.Errorln("and verify that the `delete_repo` scope is enabled.") } utils.Check(err) ui.Printf("Deleted repository '%s'.\n", project) } args.NoForward() } ================================================ FILE: commands/fetch.go ================================================ package commands import ( "fmt" "regexp" "strings" "github.com/github/hub/v2/github" "github.com/github/hub/v2/utils" ) var cmdFetch = &Command{ Run: fetch, GitExtension: true, Usage: "fetch [,...]", Long: `Add missing remotes prior to performing git fetch. ## Examples: $ hub fetch --multiple jingweno mislav > git remote add jingweno git://github.com/jingweno/REPO.git > git remote add mislav git://github.com/mislav/REPO.git > git fetch jingweno > git fetch mislav ## See also: hub-remote(1), hub(1), git-fetch(1) `, } func init() { CmdRunner.Use(cmdFetch) } func fetch(command *Command, args *Args) { if !args.IsParamsEmpty() { err := transformFetchArgs(args) utils.Check(err) } } func transformFetchArgs(args *Args) error { names := parseRemoteNames(args) localRepo, err := github.LocalRepo() utils.Check(err) currentProject, currentProjectErr := localRepo.CurrentProject() projects := make(map[*github.Project]bool) ownerRegexp := regexp.MustCompile(fmt.Sprintf("^%s$", OwnerRe)) for _, name := range names { if ownerRegexp.MatchString(name) && !isCloneable(name) { _, err := localRepo.RemoteByName(name) if err != nil { utils.Check(currentProjectErr) project := github.NewProject(name, currentProject.Name, "") gh := github.NewClient(project.Host) repo, err := gh.Repository(project) if err != nil { continue } projects[project] = repo.Private || repo.Permissions.Push } } } for project, private := range projects { args.Before("git", "remote", "add", project.Owner, project.GitURL("", "", private)) } return nil } func parseRemoteNames(args *Args) (names []string) { words := args.Words() if i := args.IndexOfParam("--multiple"); i != -1 { if args.ParamsSize() > 1 { names = words } } else if len(words) > 0 { remoteName := words[0] commaPattern := fmt.Sprintf("^%s(,%s)+$", OwnerRe, OwnerRe) remoteNameRegexp := regexp.MustCompile(commaPattern) if remoteNameRegexp.MatchString(remoteName) { i := args.IndexOfParam(remoteName) args.RemoveParam(i) names = strings.Split(remoteName, ",") args.InsertParam(i, names...) args.InsertParam(i, "--multiple") } else { names = append(names, remoteName) } } return } ================================================ FILE: commands/fetch_test.go ================================================ package commands import ( "github.com/github/hub/v2/internal/assert" "testing" ) func TestParseRemoteNames(t *testing.T) { args := NewArgs([]string{"fetch", "jingweno,foo"}) names := parseRemoteNames(args) assert.Equal(t, 2, len(names)) assert.Equal(t, "jingweno", names[0]) assert.Equal(t, "foo", names[1]) cmd := args.ToCmd() assert.Equal(t, "git fetch --multiple jingweno foo", cmd.String()) args = NewArgs([]string{"fetch", "--multiple", "jingweno", "foo"}) names = parseRemoteNames(args) assert.Equal(t, 2, len(names)) assert.Equal(t, "jingweno", names[0]) assert.Equal(t, "foo", names[1]) args = NewArgs([]string{"fetch", "--multiple"}) names = parseRemoteNames(args) assert.Equal(t, 0, len(names)) } ================================================ FILE: commands/fork.go ================================================ package commands import ( "fmt" "github.com/github/hub/v2/github" "github.com/github/hub/v2/ui" "github.com/github/hub/v2/utils" ) var cmdFork = &Command{ Run: fork, Usage: "fork [--no-remote] [--remote-name ] [--org ]", Long: `Fork the current repository on GitHub and add a git remote for it. ## Options: --no-remote Skip adding a git remote for the fork. --remote-name Set the name for the new git remote. --org Fork the repository within this organization. ## Examples: $ hub fork [ repo forked on GitHub ] > git remote add -f USER git@github.com:USER/REPO.git $ hub fork --org=ORGANIZATION [ repo forked on GitHub into the ORGANIZATION organization] > git remote add -f ORGANIZATION git@github.com:ORGANIZATION/REPO.git ## See also: hub-clone(1), hub(1) `, } func init() { CmdRunner.Use(cmdFork) } func fork(cmd *Command, args *Args) { localRepo, err := github.LocalRepo() utils.Check(err) project, err := localRepo.MainProject() utils.Check(err) config := github.CurrentConfig() host, err := config.PromptForHost(project.Host) utils.Check(github.FormatError("forking repository", err)) params := map[string]interface{}{} forkOwner := host.User if flagForkOrganization := args.Flag.Value("--org"); flagForkOrganization != "" { forkOwner = flagForkOrganization params["organization"] = forkOwner } forkProject := github.NewProject(forkOwner, project.Name, project.Host) var newRemoteName string if flagForkRemoteName := args.Flag.Value("--remote-name"); flagForkRemoteName != "" { newRemoteName = flagForkRemoteName } else { newRemoteName = forkProject.Owner } client := github.NewClient(project.Host) existingRepo, err := client.Repository(forkProject) if err == nil { existingProject, err := github.NewProjectFromRepo(existingRepo) if err == nil && !existingProject.SameAs(forkProject) { existingRepo = nil } } if err == nil && existingRepo != nil { var parentURL *github.URL if parent := existingRepo.Parent; parent != nil { parentURL, _ = github.ParseURL(parent.HTMLURL) } if parentURL == nil || !project.SameAs(parentURL.Project) { err = fmt.Errorf("Error creating fork: %s already exists on %s", forkProject, forkProject.Host) utils.Check(err) } } else { if !args.Noop { newRepo, err := client.ForkRepository(project, params) utils.Check(err) forkProject.Owner = newRepo.Owner.Login forkProject.Name = newRepo.Name } } args.NoForward() if !args.Flag.Bool("--no-remote") { originURL := project.GitURL("", "", false) url := forkProject.GitURL("", "", true) // Check to see if the remote already exists. currentRemote, err := localRepo.RemoteByName(newRemoteName) if err == nil { currentProject, err := currentRemote.Project() if err == nil { if currentProject.SameAs(forkProject) { ui.Printf("existing remote: %s\n", newRemoteName) return } if newRemoteName == "origin" { // Assume user wants to follow github guides for collaboration ui.Printf("renaming existing \"origin\" remote to \"upstream\"\n") args.Before("git", "remote", "rename", "origin", "upstream") } } } args.Before("git", "remote", "add", "-f", newRemoteName, originURL) args.Before("git", "remote", "set-url", newRemoteName, url) args.AfterFn(func() error { ui.Printf("new remote: %s\n", newRemoteName) return nil }) } } ================================================ FILE: commands/gist.go ================================================ package commands import ( "fmt" "sort" "strings" "github.com/github/hub/v2/github" "github.com/github/hub/v2/ui" "github.com/github/hub/v2/utils" ) var ( cmdGist = &Command{ Run: printGistHelp, Usage: ` gist create [-oc] [--public] [...] gist show [] `, Long: `Create and print GitHub Gists ## Commands: * _create_: Create a new gist. If no are specified, the content is read from standard input. * _show_: Print the contents of a gist. If the gist contains multiple files, the operation will error out unless is specified. ## Options: --public Make the new gist public (default: false). -o, --browse Open the new gist in a web browser. -c, --copy Put the URL of the new gist to clipboard instead of printing it. ## Examples: $ echo hello | hub gist create --public $ hub gist create file1.txt file2.txt # print a specific file within a gist: $ hub gist show ID testfile1.txt ## See also: hub(1), hub-api(1) `, } cmdShowGist = &Command{ Key: "show", Run: showGist, } cmdCreateGist = &Command{ Key: "create", Run: createGist, KnownFlags: ` --public -o, --browse -c, --copy `, } ) func init() { cmdGist.Use(cmdShowGist) cmdGist.Use(cmdCreateGist) CmdRunner.Use(cmdGist) } func getGist(gh *github.Client, id string, filename string) error { gist, err := gh.FetchGist(id) if err != nil { return err } if len(gist.Files) > 1 && filename == "" { filenames := []string{} for name := range gist.Files { filenames = append(filenames, name) } sort.Strings(filenames) return fmt.Errorf("This gist contains multiple files, you must specify one:\n %s", strings.Join(filenames, "\n ")) } if filename != "" { if val, ok := gist.Files[filename]; ok { ui.Println(val.Content) } else { return fmt.Errorf("no such file in gist") } } else { for name := range gist.Files { file := gist.Files[name] ui.Println(file.Content) } } return nil } func printGistHelp(command *Command, args *Args) { utils.Check(command.UsageError("")) } func createGist(cmd *Command, args *Args) { args.NoForward() host, err := github.CurrentConfig().DefaultHostNoPrompt() utils.Check(err) gh := github.NewClient(host.Host) filenames := []string{} if args.IsParamsEmpty() { filenames = append(filenames, "-") } else { filenames = args.Params } var gist *github.Gist if args.Noop { ui.Println("Would create gist") gist = &github.Gist{ HTMLURL: fmt.Sprintf("https://gist.%s/%s", gh.Host.Host, "ID"), } } else { gist, err = gh.CreateGist(filenames, args.Flag.Bool("--public")) utils.Check(err) } flagIssueBrowse := args.Flag.Bool("--browse") flagIssueCopy := args.Flag.Bool("--copy") printBrowseOrCopy(args, gist.HTMLURL, flagIssueBrowse, flagIssueCopy) } func showGist(cmd *Command, args *Args) { args.NoForward() if args.ParamsSize() < 1 { utils.Check(cmd.UsageError("you must specify a gist ID")) } host, err := github.CurrentConfig().DefaultHostNoPrompt() utils.Check(err) gh := github.NewClient(host.Host) id := args.GetParam(0) filename := "" if args.ParamsSize() > 1 { filename = args.GetParam(1) } err = getGist(gh, id, filename) utils.Check(err) } ================================================ FILE: commands/help.go ================================================ package commands import ( "fmt" "os" "os/exec" "path/filepath" "sort" "strings" "github.com/github/hub/v2/git" "github.com/github/hub/v2/ui" "github.com/github/hub/v2/utils" "github.com/kballard/go-shellquote" ) var cmdHelp = &Command{ Run: runHelp, GitExtension: true, Usage: ` help hub help help hub- [--plain-text] `, Long: `Show the help page for a command. ## Options: hub- Use this format to view help for hub extensions to an existing git command. --plain-text Skip man page lookup mechanism and display raw help text. ## See also: hub(1), git-help(1) `, } var cmdListCmds = &Command{ Key: "--list-cmds", Run: runListCmds, GitExtension: true, } func init() { CmdRunner.Use(cmdHelp, "--help") CmdRunner.Use(cmdListCmds) } func runHelp(helpCmd *Command, args *Args) { if args.IsParamsEmpty() { args.AfterFn(func() error { ui.Println(helpText) return nil }) return } p := utils.NewArgsParser() p.RegisterBool("--all", "-a") p.RegisterBool("--plain-text") p.RegisterBool("--man", "-m") p.RegisterBool("--web", "-w") p.Parse(args.Params) if p.Bool("--all") { args.AfterFn(func() error { ui.Printf("\nhub custom commands\n\n %s\n", strings.Join(customCommands(), " ")) return nil }) return } isWeb := func() bool { if p.Bool("--web") { return true } if p.Bool("--man") { return false } if f, err := git.Config("help.format"); err == nil { return f == "web" || f == "html" } return false } cmdName := "" if words := args.Words(); len(words) > 0 { cmdName = words[0] } if cmdName == "hub" { err := displayManPage("hub", args, isWeb()) utils.Check(err) return } foundCmd := lookupCmd(cmdName) if foundCmd == nil { return } if p.Bool("--plain-text") { ui.Println(foundCmd.HelpText()) os.Exit(0) } manPage := fmt.Sprintf("hub-%s", foundCmd.Name()) err := displayManPage(manPage, args, isWeb()) utils.Check(err) } func runListCmds(cmd *Command, args *Args) { listOthers := false parts := strings.SplitN(args.Command, "=", 2) for _, kind := range strings.Split(parts[1], ",") { if kind == "others" { listOthers = true break } } if listOthers { args.AfterFn(func() error { ui.Println(strings.Join(customCommands(), "\n")) return nil }) } } // On systems where `man` was found, invoke: // MANPATH={PREFIX}/share/man:$MANPATH man // // otherwise: // less -R {PREFIX}/share/man/man1/.1.txt func displayManPage(manPage string, args *Args, isWeb bool) error { programPath, err := utils.CommandPath(args.ProgramPath) if err != nil { return err } if isWeb { manPage += ".1.html" manFile := filepath.Join(programPath, "..", "..", "share", "doc", "hub-doc", manPage) args.Replace(args.Executable, "web--browse", manFile) return nil } var manArgs []string manProgram, _ := utils.CommandPath("man") if manProgram != "" { manArgs = []string{manProgram} } else { manPage += ".1.txt" if manProgram = os.Getenv("PAGER"); manProgram != "" { var err error manArgs, err = shellquote.Split(manProgram) if err != nil { return err } } else { manArgs = []string{"less", "-R"} } } env := os.Environ() if strings.HasSuffix(manPage, ".txt") { manFile := filepath.Join(programPath, "..", "..", "share", "man", "man1", manPage) manArgs = append(manArgs, manFile) } else { manArgs = append(manArgs, manPage) manPath := filepath.Join(programPath, "..", "..", "share", "man") env = append(env, fmt.Sprintf("MANPATH=%s:%s", manPath, os.Getenv("MANPATH"))) } c := exec.Command(manArgs[0], manArgs[1:]...) c.Stdin = os.Stdin c.Stdout = os.Stdout c.Stderr = os.Stderr c.Env = env if err := c.Run(); err != nil { return err } os.Exit(0) return nil } func lookupCmd(name string) *Command { if strings.HasPrefix(name, "hub-") { return CmdRunner.Lookup(strings.TrimPrefix(name, "hub-")) } cmd := CmdRunner.Lookup(name) if cmd != nil && !cmd.GitExtension { return cmd } return nil } func customCommands() []string { cmds := []string{} for n, c := range CmdRunner.All() { if !c.GitExtension && !strings.HasPrefix(n, "--") { cmds = append(cmds, n) } } sort.Strings(cmds) return cmds } var helpText = ` These GitHub commands are provided by hub: api Low-level GitHub API request interface browse Open a GitHub page in the default browser ci-status Show the status of GitHub checks for a commit compare Open a compare page on GitHub create Create this repository on GitHub and add GitHub as origin delete Delete a repository on GitHub fork Make a fork of a remote repository on GitHub and add as remote gist Make a gist issue List or create GitHub issues pr Manage GitHub pull requests pull-request Open a pull request on GitHub release List or create GitHub releases sync Fetch git objects from upstream and update branches ` ================================================ FILE: commands/init.go ================================================ package commands import ( "path/filepath" "regexp" "strings" "github.com/github/hub/v2/github" "github.com/github/hub/v2/utils" ) var cmdInit = &Command{ Run: gitInit, GitExtension: true, Usage: "init -g", Long: `Initialize a git repository and add a remote pointing to GitHub. ## Options: -g After initializing the repository locally, add the "origin" remote pointing to "/" repository on GitHub. is your GitHub username, while is the name of the current working directory. ## Examples: $ hub init -g > git init > git remote add origin git@github.com:USER/REPO.git ## See also: hub-create(1), hub(1), git-init(1) `, } func init() { CmdRunner.Use(cmdInit) } func gitInit(command *Command, args *Args) { err := transformInitArgs(args) utils.Check(err) } func transformInitArgs(args *Args) error { if !parseInitFlag(args) { return nil } var err error dirToInit := "." hasValueRegexp := regexp.MustCompile("^--(template|separate-git-dir|shared)$") // Find the first argument that isn't related to any of the init flags. // We assume this is the optional `directory` argument to git init. for i := 0; i < args.ParamsSize(); i++ { arg := args.Params[i] if hasValueRegexp.MatchString(arg) { i++ } else if !strings.HasPrefix(arg, "-") { dirToInit = arg break } } dirToInit, err = filepath.Abs(dirToInit) if err != nil { return err } config := github.CurrentConfig() host, err := config.DefaultHost() if err != nil { utils.Check(github.FormatError("initializing repository", err)) } // Assume that the name of the working directory is going to be the name of // the project on GitHub. projectName := strings.Replace(filepath.Base(dirToInit), " ", "-", -1) project := github.NewProject(host.User, projectName, host.Host) url := project.GitURL("", "", true) addRemote := []string{ "git", "--git-dir", filepath.Join(dirToInit, ".git"), "remote", "add", "origin", url, } args.After(addRemote...) return nil } func parseInitFlag(args *Args) bool { if i := args.IndexOfParam("-g"); i != -1 { args.RemoveParam(i) return true } return false } ================================================ FILE: commands/init_test.go ================================================ package commands import ( "fmt" "os" "path/filepath" "testing" "github.com/github/hub/v2/github" "github.com/github/hub/v2/internal/assert" ) func setupInitContext() { os.Setenv("HUB_PROTOCOL", "git") os.Setenv("HUB_CONFIG", "") github.CreateTestConfigs("jingweno", "123") } func TestEmptyParams(t *testing.T) { setupInitContext() args := NewArgs([]string{"init"}) err := transformInitArgs(args) assert.Equal(t, nil, err) assert.Equal(t, true, args.IsParamsEmpty()) } func TestFlagToAddRemote(t *testing.T) { setupInitContext() args := NewArgs([]string{"init", "-g", "--quiet"}) err := transformInitArgs(args) assert.Equal(t, nil, err) commands := args.Commands() assert.Equal(t, 2, len(commands)) assert.Equal(t, "git init --quiet", commands[0].String()) currentDir, err := os.Getwd() assert.Equal(t, nil, err) expected := fmt.Sprintf( "git --git-dir %s remote add origin git@github.com:jingweno/%s.git", filepath.Join(currentDir, ".git"), filepath.Base(currentDir), ) assert.Equal(t, expected, commands[1].String()) } func TestInitInAnotherDir(t *testing.T) { setupInitContext() args := NewArgs([]string{"init", "-g", "--template", "mytpl", "--shared=umask", "my project"}) err := transformInitArgs(args) assert.Equal(t, nil, err) commands := args.Commands() assert.Equal(t, 2, len(commands)) assert.Equal(t, "git init --template mytpl --shared=umask \"my project\"", commands[0].String()) currentDir, err := os.Getwd() assert.Equal(t, nil, err) expected := fmt.Sprintf( "git --git-dir \"%s\" remote add origin git@github.com:jingweno/%s.git", filepath.Join(currentDir, "my project", ".git"), "my-project", ) assert.Equal(t, expected, commands[1].String()) } func TestSeparateGitDir(t *testing.T) { setupInitContext() args := NewArgs([]string{"init", "-g", "--separate-git-dir", "/tmp/where-i-play.git", "my/playground"}) err := transformInitArgs(args) assert.Equal(t, nil, err) commands := args.Commands() assert.Equal(t, 2, len(commands)) assert.Equal(t, "git init --separate-git-dir /tmp/where-i-play.git my/playground", commands[0].String()) currentDir, err := os.Getwd() assert.Equal(t, nil, err) expected := fmt.Sprintf( "git --git-dir %s remote add origin git@github.com:jingweno/%s.git", filepath.Join(currentDir, "my", "playground", ".git"), "playground", ) assert.Equal(t, expected, commands[1].String()) } ================================================ FILE: commands/issue.go ================================================ package commands import ( "fmt" "os" "strconv" "strings" "time" "github.com/github/hub/v2/git" "github.com/github/hub/v2/github" "github.com/github/hub/v2/ui" "github.com/github/hub/v2/utils" ) var ( cmdIssue = &Command{ Run: listIssues, Usage: ` issue [-a ] [-c ] [-@ ] [-s ] [-f ] [-M ] [-l ] [-d ] [-o [-^]] [-L ] issue show [-f ] issue create [-oc] [-m |-F ] [--edit] [-a ] [-M ] [-l ] issue update [-m |-F ] [--edit] [-a ] [-M ] [-l ] [-s ] issue labels [--color] issue transfer `, Long: `Manage GitHub Issues for the current repository. ## Commands: With no arguments, show a list of open issues. * _show_: Show an existing issue specified by . * _create_: Open an issue in the current repository. * _update_: Update fields of an existing issue specified by . Use ''--edit'' to edit the title and message interactively in the text editor. * _labels_: List the labels available in this repository. * _transfer_: Transfer an issue to another repository. ## Options: -a, --assignee In list mode, display only issues assigned to . -a, --assign A comma-separated list of GitHub handles to assign to the created issue. -c, --creator Display only issues created by . -@, --mentioned Display only issues mentioning . -s, --state Display issues with state (default: "open"). -f, --format Pretty print the contents of the issues using format (default: "%sC%>(8)%i%Creset %t% l%n"). See the "PRETTY FORMATS" section of git-log(1) for some additional details on how placeholders are used in format. The available placeholders for issues are: %I: issue number %i: issue number prefixed with "#" %U: the URL of this issue %S: state (i.e. "open", "closed") %sC: set color to red or green, depending on issue state. %t: title %l: colored labels %L: raw, comma-separated labels %b: body %au: login name of author %as: comma-separated list of assignees %Mn: milestone number %Mt: milestone title %NC: number of comments %Nc: number of comments wrapped in parentheses, or blank string if zero. %cD: created date-only (no time of day) %cr: created date, relative %ct: created date, UNIX timestamp %cI: created date, ISO 8601 format %uD: updated date-only (no time of day) %ur: updated date, relative %ut: updated date, UNIX timestamp %uI: updated date, ISO 8601 format %n: newline %%: a literal % --color[=] Enable colored output even if stdout is not a terminal. can be one of "always" (default for ''--color''), "never", or "auto" (default). -m, --message The text up to the first blank line in is treated as the issue title, and the rest is used as issue description in Markdown format. When multiple ''--message'' are passed, their values are concatenated with a blank line in-between. When neither ''--message'' nor ''--file'' were supplied to ''issue create'', a text editor will open to author the title and description in. -F, --file Read the issue title and description from . Pass "-" to read from standard input instead. See ''--message'' for the formatting rules. -e, --edit Open the issue title and description in a text editor before submitting. This can be used in combination with ''--message'' or ''--file''. -o, --browse Open the new issue in a web browser. -c, --copy Put the URL of the new issue to clipboard instead of printing it. -M, --milestone Display only issues for a GitHub milestone with the name . When opening an issue, add this issue to a GitHub milestone with the name . Passing the milestone number is deprecated. -l, --labels Display only issues with certain labels. When opening an issue, add a comma-separated list of labels to this issue. -d, --since Display only issues updated on or after in ISO 8601 format. -o, --sort Sort displayed issues by "created" (default), "updated" or "comments". -^ --sort-ascending Sort by ascending dates instead of descending. -L, --limit Display only the first issues. --include-pulls Include pull requests as well as issues. --color Enable colored output for labels list. ## See also: hub-pr(1), hub(1) `, KnownFlags: ` -a, --assignee USER -s, --state STATE -f, --format FMT -M, --milestone NAME -c, --creator USER -@, --mentioned USER -l, --labels LIST -d, --since DATE -o, --sort KEY -^, --sort-ascending --include-pulls -L, --limit N --color `, } cmdCreateIssue = &Command{ Key: "create", Run: createIssue, KnownFlags: ` -m, --message MSG -F, --file FILE -M, --milestone NAME -l, --labels LIST -a, --assign USER -o, --browse -c, --copy -e, --edit `, } cmdShowIssue = &Command{ Key: "show", Run: showIssue, KnownFlags: ` -f, --format FMT --color `, } cmdLabel = &Command{ Key: "labels", Run: listLabels, KnownFlags: ` --color `, } cmdTransfer = &Command{ Key: "transfer", Run: transferIssue, } cmdUpdate = &Command{ Key: "update", Run: updateIssue, KnownFlags: ` -m, --message MSG -F, --file FILE -M, --milestone NAME -l, --labels LIST -a, --assign USER -e, --edit -s, --state STATE `, } ) func init() { cmdIssue.Use(cmdShowIssue) cmdIssue.Use(cmdCreateIssue) cmdIssue.Use(cmdLabel) cmdIssue.Use(cmdTransfer) cmdIssue.Use(cmdUpdate) CmdRunner.Use(cmdIssue) } func listIssues(cmd *Command, args *Args) { localRepo, err := github.LocalRepo() utils.Check(err) project, err := localRepo.MainProject() utils.Check(err) gh := github.NewClient(project.Host) if args.Noop { ui.Printf("Would request list of issues for %s\n", project) } else { filters := map[string]interface{}{} if args.Flag.HasReceived("--state") { filters["state"] = args.Flag.Value("--state") } if args.Flag.HasReceived("--assignee") { filters["assignee"] = args.Flag.Value("--assignee") } if args.Flag.HasReceived("--milestone") { milestoneValue := args.Flag.Value("--milestone") if milestoneValue == "none" { filters["milestone"] = milestoneValue } else { milestoneNumber, err := milestoneValueToNumber(milestoneValue, gh, project) utils.Check(err) if milestoneNumber > 0 { filters["milestone"] = milestoneNumber } } } if args.Flag.HasReceived("--creator") { filters["creator"] = args.Flag.Value("--creator") } if args.Flag.HasReceived("--mentioned") { filters["mentioned"] = args.Flag.Value("--mentioned") } if args.Flag.HasReceived("--labels") { labels := commaSeparated(args.Flag.AllValues("--labels")) filters["labels"] = strings.Join(labels, ",") } if args.Flag.HasReceived("--sort") { filters["sort"] = args.Flag.Value("--sort") } if args.Flag.Bool("--sort-ascending") { filters["direction"] = "asc" } else { filters["direction"] = "desc" } if args.Flag.HasReceived("--since") { flagIssueSince := args.Flag.Value("--since") if sinceTime, err := time.ParseInLocation("2006-01-02", flagIssueSince, time.Local); err == nil { filters["since"] = sinceTime.Format(time.RFC3339) } else { filters["since"] = flagIssueSince } } flagIssueLimit := args.Flag.Int("--limit") flagIssueIncludePulls := args.Flag.Bool("--include-pulls") flagIssueFormat := "%sC%>(8)%i%Creset %t% l%n" if args.Flag.HasReceived("--format") { flagIssueFormat = args.Flag.Value("--format") } issues, err := gh.FetchIssues(project, filters, flagIssueLimit, func(issue *github.Issue) bool { return issue.PullRequest == nil || flagIssueIncludePulls }) utils.Check(err) maxNumWidth := 0 for _, issue := range issues { if numWidth := len(strconv.Itoa(issue.Number)); numWidth > maxNumWidth { maxNumWidth = numWidth } } colorize := colorizeOutput(args.Flag.HasReceived("--color"), args.Flag.Value("--color")) for _, issue := range issues { ui.Print(formatIssue(issue, flagIssueFormat, colorize)) } } args.NoForward() } func formatIssuePlaceholders(issue github.Issue, colorize bool) map[string]string { var stateColorSwitch string if colorize { issueColor := 32 if issue.State == "closed" { issueColor = 31 } stateColorSwitch = fmt.Sprintf("\033[%dm", issueColor) } var labelStrings []string var rawLabels []string for _, label := range issue.Labels { if colorize { color, err := utils.NewColor(label.Color) utils.Check(err) labelStrings = append(labelStrings, colorizeLabel(label, color)) } else { labelStrings = append(labelStrings, fmt.Sprintf(" %s ", label.Name)) } rawLabels = append(rawLabels, label.Name) } var assignees []string for _, assignee := range issue.Assignees { assignees = append(assignees, assignee.Login) } var milestoneNumber, milestoneTitle string if issue.Milestone != nil { milestoneNumber = fmt.Sprintf("%d", issue.Milestone.Number) milestoneTitle = issue.Milestone.Title } var numCommentsWrapped string numComments := fmt.Sprintf("%d", issue.Comments) if issue.Comments > 0 { numCommentsWrapped = fmt.Sprintf("(%d)", issue.Comments) } var createdDate, createdAtISO8601, createdAtUnix, createdAtRelative, updatedDate, updatedAtISO8601, updatedAtUnix, updatedAtRelative string if !issue.CreatedAt.IsZero() { createdDate = issue.CreatedAt.Format("02 Jan 2006") createdAtISO8601 = issue.CreatedAt.Format(time.RFC3339) createdAtUnix = fmt.Sprintf("%d", issue.CreatedAt.Unix()) createdAtRelative = utils.TimeAgo(issue.CreatedAt) } if !issue.UpdatedAt.IsZero() { updatedDate = issue.UpdatedAt.Format("02 Jan 2006") updatedAtISO8601 = issue.UpdatedAt.Format(time.RFC3339) updatedAtUnix = fmt.Sprintf("%d", issue.UpdatedAt.Unix()) updatedAtRelative = utils.TimeAgo(issue.UpdatedAt) } return map[string]string{ "I": fmt.Sprintf("%d", issue.Number), "i": fmt.Sprintf("#%d", issue.Number), "U": issue.HTMLURL, "S": issue.State, "sC": stateColorSwitch, "t": issue.Title, "l": strings.Join(labelStrings, " "), "L": strings.Join(rawLabels, ", "), "b": issue.Body, "au": issue.User.Login, "as": strings.Join(assignees, ", "), "Mn": milestoneNumber, "Mt": milestoneTitle, "NC": numComments, "Nc": numCommentsWrapped, "cD": createdDate, "cI": createdAtISO8601, "ct": createdAtUnix, "cr": createdAtRelative, "uD": updatedDate, "uI": updatedAtISO8601, "ut": updatedAtUnix, "ur": updatedAtRelative, } } func formatPullRequestPlaceholders(pr github.PullRequest, colorize bool) map[string]string { prState := pr.State if prState == "open" && pr.Draft { prState = "draft" } else if !pr.MergedAt.IsZero() { prState = "merged" } var stateColorSwitch string var prColor int if colorize { switch prState { case "draft": prColor = 37 case "merged": prColor = 35 case "closed": prColor = 31 default: prColor = 32 } stateColorSwitch = fmt.Sprintf("\033[%dm", prColor) } base := pr.Base.Ref head := pr.Head.Label if pr.IsSameRepo() { head = pr.Head.Ref } var requestedReviewers []string for _, requestedReviewer := range pr.RequestedReviewers { requestedReviewers = append(requestedReviewers, requestedReviewer.Login) } for _, requestedTeam := range pr.RequestedTeams { teamSlug := fmt.Sprintf("%s/%s", pr.Base.Repo.Owner.Login, requestedTeam.Slug) requestedReviewers = append(requestedReviewers, teamSlug) } var mergedDate, mergedAtISO8601, mergedAtUnix, mergedAtRelative string if !pr.MergedAt.IsZero() { mergedDate = pr.MergedAt.Format("02 Jan 2006") mergedAtISO8601 = pr.MergedAt.Format(time.RFC3339) mergedAtUnix = fmt.Sprintf("%d", pr.MergedAt.Unix()) mergedAtRelative = utils.TimeAgo(pr.MergedAt) } return map[string]string{ "pS": prState, "pC": stateColorSwitch, "B": base, "H": head, "sB": pr.Base.Sha, "sH": pr.Head.Sha, "sm": pr.MergeCommitSha, "rs": strings.Join(requestedReviewers, ", "), "mD": mergedDate, "mI": mergedAtISO8601, "mt": mergedAtUnix, "mr": mergedAtRelative, } } func formatIssue(issue github.Issue, format string, colorize bool) string { placeholders := formatIssuePlaceholders(issue, colorize) return ui.Expand(format, placeholders, colorize) } func showIssue(cmd *Command, args *Args) { issueNumber := "" if args.ParamsSize() > 0 { issueNumber = args.GetParam(0) } if issueNumber == "" { utils.Check(cmd.UsageError("")) } localRepo, err := github.LocalRepo() utils.Check(err) project, err := localRepo.MainProject() utils.Check(err) gh := github.NewClient(project.Host) var issue = &github.Issue{} issue, err = gh.FetchIssue(project, issueNumber) utils.Check(err) args.NoForward() colorize := colorizeOutput(args.Flag.HasReceived("--color"), args.Flag.Value("--color")) if args.Flag.HasReceived("--format") { flagShowIssueFormat := args.Flag.Value("--format") ui.Print(formatIssue(*issue, flagShowIssueFormat, colorize)) return } var closed = "" if issue.State != "open" { closed = "[CLOSED] " } commentsList, err := gh.FetchComments(project, issueNumber) utils.Check(err) ui.Printf("# %s%s\n\n", closed, issue.Title) ui.Printf("* created by @%s on %s\n", issue.User.Login, issue.CreatedAt.String()) if len(issue.Assignees) > 0 { var assignees []string for _, user := range issue.Assignees { assignees = append(assignees, user.Login) } ui.Printf("* assignees: %s\n", strings.Join(assignees, ", ")) } ui.Printf("\n%s\n", issue.Body) if issue.Comments > 0 { ui.Printf("\n## Comments:\n") for _, comment := range commentsList { ui.Printf("\n### comment by @%s on %s\n\n%s\n", comment.User.Login, comment.CreatedAt.String(), comment.Body) } } } func createIssue(cmd *Command, args *Args) { localRepo, err := github.LocalRepo() utils.Check(err) project, err := localRepo.MainProject() utils.Check(err) gh := github.NewClient(project.Host) messageBuilder := &github.MessageBuilder{ Filename: "ISSUE_EDITMSG", Title: "issue", } messageBuilder.AddCommentedSection(fmt.Sprintf(`Creating an issue for %s Write a message for this issue. The first block of text is the title and the rest is the description.`, project)) flagIssueEdit := args.Flag.Bool("--edit") flagIssueMessage := args.Flag.AllValues("--message") if len(flagIssueMessage) > 0 { messageBuilder.Message = strings.Join(flagIssueMessage, "\n\n") messageBuilder.Edit = flagIssueEdit } else if args.Flag.HasReceived("--file") { messageBuilder.Message, err = msgFromFile(args.Flag.Value("--file")) utils.Check(err) messageBuilder.Edit = flagIssueEdit } else { messageBuilder.Edit = true workdir, _ := git.WorkdirName() if workdir != "" { template, err := github.ReadTemplate(github.IssueTemplate, workdir) utils.Check(err) if template != "" { messageBuilder.Message = template } } } title, body, err := messageBuilder.Extract() utils.Check(err) if title == "" { utils.Check(fmt.Errorf("Aborting creation due to empty issue title")) } params := map[string]interface{}{ "title": title, "body": body, } setLabelsFromArgs(params, args) setAssigneesFromArgs(params, args) setMilestoneFromArgs(params, args, gh, project) args.NoForward() if args.Noop { ui.Printf("Would create issue `%s' for %s\n", params["title"], project) } else { issue, err := gh.CreateIssue(project, params) utils.Check(err) flagIssueBrowse := args.Flag.Bool("--browse") flagIssueCopy := args.Flag.Bool("--copy") printBrowseOrCopy(args, issue.HTMLURL, flagIssueBrowse, flagIssueCopy) } messageBuilder.Cleanup() } func updateIssue(cmd *Command, args *Args) { issueNumber := 0 if args.ParamsSize() > 0 { issueNumber, _ = strconv.Atoi(args.GetParam(0)) } if issueNumber == 0 { utils.Check(cmd.UsageError("")) } if !hasField(args, "--message", "--file", "--labels", "--milestone", "--assign", "--state", "--edit") { utils.Check(cmd.UsageError("please specify fields to update")) } localRepo, err := github.LocalRepo() utils.Check(err) project, err := localRepo.MainProject() utils.Check(err) gh := github.NewClient(project.Host) params := map[string]interface{}{} setLabelsFromArgs(params, args) setAssigneesFromArgs(params, args) setMilestoneFromArgs(params, args, gh, project) if args.Flag.HasReceived("--state") { params["state"] = args.Flag.Value("--state") } if hasField(args, "--message", "--file", "--edit") { messageBuilder := &github.MessageBuilder{ Filename: "ISSUE_EDITMSG", Title: "issue", } messageBuilder.AddCommentedSection(fmt.Sprintf(`Editing issue #%d for %s Update the message for this issue. The first block of text is the title and the rest is the description.`, issueNumber, project)) messageBuilder.Edit = args.Flag.Bool("--edit") flagIssueMessage := args.Flag.AllValues("--message") if len(flagIssueMessage) > 0 { messageBuilder.Message = strings.Join(flagIssueMessage, "\n\n") } else if args.Flag.HasReceived("--file") { messageBuilder.Message, err = msgFromFile(args.Flag.Value("--file")) utils.Check(err) } else { issue, err := gh.FetchIssue(project, strconv.Itoa(issueNumber)) utils.Check(err) existingMessage := fmt.Sprintf("%s\n\n%s", issue.Title, issue.Body) messageBuilder.Message = strings.Replace(existingMessage, "\r\n", "\n", -1) } title, body, err := messageBuilder.Extract() utils.Check(err) if title == "" { utils.Check(fmt.Errorf("Aborting creation due to empty issue title")) } params["title"] = title params["body"] = body defer messageBuilder.Cleanup() } args.NoForward() if args.Noop { ui.Printf("Would update issue #%d for %s\n", issueNumber, project) } else { err := gh.UpdateIssue(project, issueNumber, params) utils.Check(err) } } func listLabels(cmd *Command, args *Args) { localRepo, err := github.LocalRepo() utils.Check(err) project, err := localRepo.MainProject() utils.Check(err) gh := github.NewClient(project.Host) args.NoForward() if args.Noop { ui.Printf("Would request list of labels for %s\n", project) return } labels, err := gh.FetchLabels(project) utils.Check(err) flagLabelsColorize := colorizeOutput(args.Flag.HasReceived("--color"), args.Flag.Value("--color")) for _, label := range labels { ui.Print(formatLabel(label, flagLabelsColorize)) } } func hasField(args *Args, names ...string) bool { found := false for _, name := range names { if args.Flag.HasReceived(name) { found = true } } return found } func setLabelsFromArgs(params map[string]interface{}, args *Args) { if !args.Flag.HasReceived("--labels") { return } params["labels"] = commaSeparated(args.Flag.AllValues("--labels")) } func setAssigneesFromArgs(params map[string]interface{}, args *Args) { if !args.Flag.HasReceived("--assign") { return } params["assignees"] = commaSeparated(args.Flag.AllValues("--assign")) } func setMilestoneFromArgs(params map[string]interface{}, args *Args, gh *github.Client, project *github.Project) { if !args.Flag.HasReceived("--milestone") { return } milestoneNumber, err := milestoneValueToNumber(args.Flag.Value("--milestone"), gh, project) utils.Check(err) if milestoneNumber == 0 { params["milestone"] = nil } else { params["milestone"] = milestoneNumber } } func colorizeOutput(colorSet bool, when string) bool { if !colorSet || when == "auto" { colorConfig, _ := git.Config("color.ui") switch colorConfig { case "false", "never": return false case "always": return true } return ui.IsTerminal(os.Stdout) } else if when == "never" { return false } else { return true // "always" } } func formatLabel(label github.IssueLabel, colorize bool) string { if colorize { if color, err := utils.NewColor(label.Color); err == nil { return fmt.Sprintf("%s\n", colorizeLabel(label, color)) } } return fmt.Sprintf("%s\n", label.Name) } func colorizeLabel(label github.IssueLabel, color *utils.Color) string { bgColorCode := utils.RgbToTermColorCode(color) fgColor := pickHighContrastTextColor(color) fgColorCode := utils.RgbToTermColorCode(fgColor) return fmt.Sprintf("\033[38;%s;48;%sm %s \033[m", fgColorCode, bgColorCode, label.Name) } type contrastCandidate struct { color *utils.Color contrast float64 } func pickHighContrastTextColor(color *utils.Color) *utils.Color { candidates := []contrastCandidate{} appendCandidate := func(c *utils.Color) { candidates = append(candidates, contrastCandidate{ color: c, contrast: color.ContrastRatio(c), }) } appendCandidate(utils.White) appendCandidate(utils.Black) for _, candidate := range candidates { if candidate.contrast >= 7.0 { return candidate.color } } for _, candidate := range candidates { if candidate.contrast >= 4.5 { return candidate.color } } return utils.Black } func milestoneValueToNumber(value string, client *github.Client, project *github.Project) (int, error) { if value == "" { return 0, nil } if milestoneNumber, err := strconv.Atoi(value); err == nil { return milestoneNumber, nil } milestones, err := client.FetchMilestones(project) if err != nil { return 0, err } for _, milestone := range milestones { if strings.EqualFold(milestone.Title, value) { return milestone.Number, nil } } return 0, fmt.Errorf("error: no milestone found with name '%s'", value) } func transferIssue(cmd *Command, args *Args) { if args.ParamsSize() < 2 { utils.Check(cmd.UsageError("")) } localRepo, err := github.LocalRepo() utils.Check(err) project, err := localRepo.MainProject() utils.Check(err) issueNumber, err := strconv.Atoi(args.GetParam(0)) utils.Check(err) targetOwner := project.Owner targetRepo := args.GetParam(1) if strings.Contains(targetRepo, "/") { parts := strings.SplitN(targetRepo, "/", 2) targetOwner = parts[0] targetRepo = parts[1] } gh := github.NewClient(project.Host) nodeIDsResponse := struct { Source struct { Issue struct { ID string } } Target struct { ID string } }{} err = gh.GraphQL(` query($issue: Int!, $sourceOwner: String!, $sourceRepo: String!, $targetOwner: String!, $targetRepo: String!) { source: repository(owner: $sourceOwner, name: $sourceRepo) { issue(number: $issue) { id } } target: repository(owner: $targetOwner, name: $targetRepo) { id } }`, map[string]interface{}{ "issue": issueNumber, "sourceOwner": project.Owner, "sourceRepo": project.Name, "targetOwner": targetOwner, "targetRepo": targetRepo, }, &nodeIDsResponse) utils.Check(err) issueResponse := struct { TransferIssue struct { Issue struct { URL string } } }{} err = gh.GraphQL(` mutation($issue: ID!, $repo: ID!) { transferIssue(input: {issueId: $issue, repositoryId: $repo}) { issue { url } } }`, map[string]interface{}{ "issue": nodeIDsResponse.Source.Issue.ID, "repo": nodeIDsResponse.Target.ID, }, &issueResponse) utils.Check(err) ui.Println(issueResponse.TransferIssue.Issue.URL) args.NoForward() } ================================================ FILE: commands/issue_test.go ================================================ package commands import ( "testing" "time" "github.com/github/hub/v2/github" ) type formatIssueTest struct { name string issue github.Issue format string colorize bool expect string } func testFormatIssue(t *testing.T, tests []formatIssueTest) { t.Helper() for _, test := range tests { if got := formatIssue(test.issue, test.format, test.colorize); got != test.expect { t.Errorf("%s: formatIssue(..., %q, %t) = %q, want %q", test.name, test.format, test.colorize, got, test.expect) } } } func TestFormatIssue(t *testing.T) { format := "%sC%>(8)%i%Creset %t% l%n" testFormatIssue(t, []formatIssueTest{ { name: "standard usage", issue: github.Issue{ Number: 42, Title: "Just an Issue", State: "open", User: &github.User{Login: "pcorpet"}, Body: "Body of the\nissue", Assignees: []github.User{{Login: "mislav"}}, }, format: format, colorize: true, expect: "\033[32m #42\033[m Just an Issue\n", }, { name: "closed issue colored differently", issue: github.Issue{ Number: 42, Title: "Just an Issue", State: "closed", User: &github.User{Login: "octocat"}, }, format: format, colorize: true, expect: "\033[31m #42\033[m Just an Issue\n", }, { name: "labels", issue: github.Issue{ Number: 42, Title: "An issue with labels", State: "open", User: &github.User{Login: "octocat"}, Labels: []github.IssueLabel{ {Name: "bug", Color: "800000"}, {Name: "reproduced", Color: "55ff55"}, }, }, format: format, colorize: true, expect: "\033[32m #42\033[m An issue with labels \033[38;2;255;255;255;48;2;128;0;0m bug \033[m \033[38;2;0;0;0;48;2;85;255;85m reproduced \033[m\n", }, { name: "not colorized", issue: github.Issue{ Number: 42, Title: "Just an Issue", State: "open", User: &github.User{Login: "octocat"}, }, format: format, colorize: false, expect: " #42 Just an Issue\n", }, { name: "labels not colorized", issue: github.Issue{ Number: 42, Title: "An issue with labels", State: "open", User: &github.User{Login: "octocat"}, Labels: []github.IssueLabel{ {Name: "bug", Color: "880000"}, {Name: "reproduced", Color: "55ff55"}, }, }, format: format, colorize: false, expect: " #42 An issue with labels bug reproduced \n", }, }) } func TestFormatIssue_customFormatString(t *testing.T) { createdAt, err := time.Parse(time.RFC822Z, "16 Mar 15 12:34 +0000") if err != nil { t.Fatal(err) } updatedAt, err := time.Parse(time.RFC822Z, "17 Mar 15 12:34 +0900") if err != nil { t.Fatal(err) } issue := github.Issue{ Number: 42, Title: "Just an Issue", State: "open", User: &github.User{Login: "pcorpet"}, Body: "Body of the\nissue", Assignees: []github.User{ {Login: "mislav"}, {Login: "josh"}, }, Labels: []github.IssueLabel{ {Name: "bug", Color: "880000"}, {Name: "feature", Color: "008800"}, }, HTMLURL: "the://url", Comments: 12, Milestone: &github.Milestone{ Number: 31, Title: "2.2-stable", }, CreatedAt: createdAt, UpdatedAt: updatedAt, } testFormatIssue(t, []formatIssueTest{ { name: "number", issue: issue, format: "%I", colorize: true, expect: "42", }, { name: "hashed number", issue: issue, format: "%i", colorize: true, expect: "#42", }, { name: "state as text", issue: issue, format: "%S", colorize: true, expect: "open", }, { name: "state as color switch", issue: issue, format: "%sC", colorize: true, expect: "\033[32m", }, { name: "state as color switch non colorized", issue: issue, format: "%sC", colorize: false, expect: "", }, { name: "title", issue: issue, format: "%t", colorize: true, expect: "Just an Issue", }, { name: "label colorized", issue: issue, format: "%l", colorize: true, expect: "\033[38;2;255;255;255;48;2;136;0;0m bug \033[m \033[38;2;255;255;255;48;2;0;136;0m feature \033[m", }, { name: "label not colorized", issue: issue, format: "%l", colorize: false, expect: " bug feature ", }, { name: "raw labels", issue: issue, format: "%L", colorize: true, expect: "bug, feature", }, { name: "body", issue: issue, format: "%b", colorize: true, expect: "Body of the\nissue", }, { name: "user login", issue: issue, format: "%au", colorize: true, expect: "pcorpet", }, { name: "assignee login", issue: issue, format: "%as", colorize: true, expect: "mislav, josh", }, { name: "assignee login but not assigned", issue: github.Issue{ State: "open", User: &github.User{Login: "pcorpet"}, }, format: "%as", colorize: true, expect: "", }, { name: "milestone number", issue: issue, format: "%Mn", colorize: true, expect: "31", }, { name: "milestone title", issue: issue, format: "%Mt", colorize: true, expect: "2.2-stable", }, { name: "comments number", issue: issue, format: "%Nc", colorize: true, expect: "(12)", }, { name: "raw comments number", issue: issue, format: "%NC", colorize: true, expect: "12", }, { name: "issue URL", issue: issue, format: "%U", colorize: true, expect: "the://url", }, { name: "created date", issue: issue, format: "%cD", colorize: true, expect: "16 Mar 2015", }, { name: "created time ISO 8601", issue: issue, format: "%cI", colorize: true, expect: "2015-03-16T12:34:00Z", }, { name: "created time Unix", issue: issue, format: "%ct", colorize: true, expect: "1426509240", }, { name: "updated date", issue: issue, format: "%uD", colorize: true, expect: "17 Mar 2015", }, { name: "updated time ISO 8601", issue: issue, format: "%uI", colorize: true, expect: "2015-03-17T12:34:00+09:00", }, { name: "updated time Unix", issue: issue, format: "%ut", colorize: true, expect: "1426563240", }, }) } ================================================ FILE: commands/merge.go ================================================ package commands import ( "fmt" "regexp" "github.com/github/hub/v2/github" "github.com/github/hub/v2/utils" ) var cmdMerge = &Command{ Run: merge, GitExtension: true, Usage: "merge ", Long: `Merge a pull request locally with a message like the GitHub Merge Button. This creates a local merge commit in the current branch, but does not actually change the state of the pull request. However, the pull request will get auto-closed and marked as "merged" as soon as the newly created merge commit is pushed to the default branch of the remote repository. To merge a pull request remotely, use ''hub pr merge''. ## Examples: $ hub merge https://github.com/jingweno/gh/pull/73 > git fetch origin refs/pull/73/head > git merge FETCH_HEAD --no-ff -m "Merge pull request #73 from jingweno/feature..." ## See also: hub-pr(1), hub-checkout(1), hub(1), git-merge(1) `, } func init() { CmdRunner.Use(cmdMerge) } func merge(command *Command, args *Args) { if !args.IsParamsEmpty() { err := transformMergeArgs(args) utils.Check(err) } } func transformMergeArgs(args *Args) error { words := args.Words() if len(words) == 0 { return nil } mergeURL := words[0] url, err := github.ParseURL(mergeURL) if err != nil { return nil } pullURLRegex := regexp.MustCompile("^pull/(\\d+)") projectPath := url.ProjectPath() if !pullURLRegex.MatchString(projectPath) { return nil } id := pullURLRegex.FindStringSubmatch(projectPath)[1] gh := github.NewClient(url.Project.Host) pullRequest, err := gh.PullRequest(url.Project, id) if err != nil { return err } repo, err := github.LocalRepo() if err != nil { return err } remote, err := repo.RemoteForRepo(pullRequest.Base.Repo) if err != nil { return err } branch := pullRequest.Head.Ref headRepo := pullRequest.Head.Repo if headRepo == nil { return fmt.Errorf("Error: that fork is not available anymore") } args.Before("git", "fetch", remote.Name, fmt.Sprintf("refs/pull/%s/head", id)) // Remove pull request URL idx := args.IndexOfParam(mergeURL) args.RemoveParam(idx) mergeMsg := fmt.Sprintf("Merge pull request #%s from %s/%s\n\n%s", id, headRepo.Owner.Login, branch, pullRequest.Title) args.AppendParams("FETCH_HEAD", "-m", mergeMsg) if args.IndexOfParam("--ff-only") == -1 && args.IndexOfParam("--squash") == -1 && args.IndexOfParam("--ff") == -1 { i := args.IndexOfParam("-m") args.InsertParam(i, "--no-ff") } return nil } ================================================ FILE: commands/pr.go ================================================ package commands import ( "fmt" "strconv" "strings" "github.com/github/hub/v2/git" "github.com/github/hub/v2/github" "github.com/github/hub/v2/ui" "github.com/github/hub/v2/utils" ) var ( cmdPr = &Command{ Run: printHelp, Usage: ` pr list [-s ] [-h ] [-b ] [-o [-^]] [-f ] [-L ] pr checkout [] pr show [-uc] [-f ] [-h ] pr show [-uc] [-f ] pr merge [-d] [--squash | --rebase] [-m | -F ] [--head-sha ] `, Long: `Manage GitHub Pull Requests for the current repository. ## Commands: * _list_: List pull requests in the current repository. * _checkout_: Check out the head of a pull request in a new branch. To update the pull request with new commits, use ''git push''. * _show_: Open a pull request page in a web browser. When no is specified, is used to look up open pull requests and defaults to the current branch name. With ''--format'', print information about the pull request instead of opening it. * _merge_: Merge a pull request in the current repository remotely. Select an alternate merge method with ''--squash'' or ''--rebase''. Change the commit subject and body with ''--message'' or ''--file''. ## Options: -s, --state Filter pull requests by . Supported values are: "open" (default), "closed", "merged", or "all". -h, --head Show pull requests started from the specified head . The "OWNER:BRANCH" format must be used for pull requests from forks. -b, --base Show pull requests based off the specified . -f, --format Pretty print the list of pull requests using format (default: "%pC%>(8)%i%Creset %t% l%n"). See the "PRETTY FORMATS" section of git-log(1) for some additional details on how placeholders are used in format. The available placeholders are: %I: pull request number %i: pull request number prefixed with "#" %U: the URL of this pull request %S: state ("open" or "closed") %pS: pull request state ("open", "draft", "merged", or "closed") %sC: set color to red or green, depending on state %pC: set color according to pull request state %t: title %l: colored labels %L: raw, comma-separated labels %b: body %B: base branch %sB: base commit SHA %H: head branch %sH: head commit SHA %sm: merge commit SHA %au: login name of author %as: comma-separated list of assignees %rs: comma-separated list of requested reviewers %Mn: milestone number %Mt: milestone title %cD: created date-only (no time of day) %cr: created date, relative %ct: created date, UNIX timestamp %cI: created date, ISO 8601 format %uD: updated date-only (no time of day) %ur: updated date, relative %ut: updated date, UNIX timestamp %uI: updated date, ISO 8601 format %mD: merged date-only (no time of day) %mr: merged date, relative %mt: merged date, UNIX timestamp %mI: merged date, ISO 8601 format %n: newline %%: a literal % --color[=] Enable colored output even if stdout is not a terminal. can be one of "always" (default for ''--color''), "never", or "auto" (default). -o, --sort Sort displayed pull requests by "created" (default), "updated", "popularity", or "long-running". -^, --sort-ascending Sort by ascending dates instead of descending. -L, --limit Display only the first pull requests. -u, --url Print the pull request URL instead of opening it. -c, --copy Put the pull request URL to clipboard instead of opening it. -m, --message The text up to the first blank line in is treated as the commit subject for the merge commit, and the rest is used as commit body. When multiple ''--message'' are passed, their values are concatenated with a blank line in-between. -F, --file Read the subject and body for the merge commit from . Pass "-" to read from standard input instead. See ''--message'' for the formatting rules. --head-sha Ensure that the head of the pull request matches the commit SHA when merging. --squash Squash commits instead of creating a merge commit when merging a pull request. --rebase Rebase commits on top of the base branch when merging a pull request. -d, --delete-branch Delete the head branch after successfully merging a pull request. ## See also: hub-issue(1), hub-pull-request(1), hub(1) `, } cmdCheckoutPr = &Command{ Key: "checkout", Run: checkoutPr, KnownFlags: "\n", } cmdListPulls = &Command{ Key: "list", Run: listPulls, Long: cmdPr.Long, } cmdShowPr = &Command{ Key: "show", Run: showPr, KnownFlags: ` -h, --head HEAD -u, --url -c, --copy -f, --format FORMAT --color `, } cmdMergePr = &Command{ Key: "merge", Run: mergePr, KnownFlags: ` -m, --message MESSAGE -F, --file FILE --head-sha COMMIT --squash --rebase -d, --delete-branch `, } ) func init() { cmdPr.Use(cmdListPulls) cmdPr.Use(cmdCheckoutPr) cmdPr.Use(cmdShowPr) cmdPr.Use(cmdMergePr) CmdRunner.Use(cmdPr) } func printHelp(command *Command, args *Args) { utils.Check(command.UsageError("")) } func listPulls(cmd *Command, args *Args) { localRepo, err := github.LocalRepo() utils.Check(err) project, err := localRepo.MainProject() utils.Check(err) gh := github.NewClient(project.Host) args.NoForward() if args.Noop { ui.Printf("Would request list of pull requests for %s\n", project) return } filters := map[string]interface{}{} if args.Flag.HasReceived("--state") { filters["state"] = args.Flag.Value("--state") } if args.Flag.HasReceived("--sort") { filters["sort"] = args.Flag.Value("--sort") } if args.Flag.HasReceived("--base") { filters["base"] = args.Flag.Value("--base") } if args.Flag.HasReceived("--head") { head := args.Flag.Value("--head") if !strings.Contains(head, ":") { head = fmt.Sprintf("%s:%s", project.Owner, head) } filters["head"] = head } if args.Flag.Bool("--sort-ascending") { filters["direction"] = "asc" } else { filters["direction"] = "desc" } onlyMerged := false if filters["state"] == "merged" { filters["state"] = "closed" onlyMerged = true } flagPullRequestLimit := args.Flag.Int("--limit") flagPullRequestFormat := args.Flag.Value("--format") if !args.Flag.HasReceived("--format") { flagPullRequestFormat = "%pC%>(8)%i%Creset %t% l%n" } pulls, err := gh.FetchPullRequests(project, filters, flagPullRequestLimit, func(pr *github.PullRequest) bool { return !(onlyMerged && pr.MergedAt.IsZero()) }) utils.Check(err) colorize := colorizeOutput(args.Flag.HasReceived("--color"), args.Flag.Value("--color")) for _, pr := range pulls { ui.Print(formatPullRequest(pr, flagPullRequestFormat, colorize)) } } func checkoutPr(command *Command, args *Args) { words := args.Words() var newBranchName string if len(words) == 0 { utils.Check(fmt.Errorf("Error: No pull request number given")) } else if len(words) > 1 { newBranchName = words[1] } prNumberString := words[0] _, err := strconv.Atoi(prNumberString) utils.Check(err) // Figure out the PR URL localRepo, err := github.LocalRepo() utils.Check(err) baseProject, err := localRepo.MainProject() utils.Check(err) host, err := github.CurrentConfig().PromptForHost(baseProject.Host) utils.Check(err) client := github.NewClientWithHost(host) pr, err := client.PullRequest(baseProject, prNumberString) utils.Check(err) newArgs, err := transformCheckoutArgs(args, pr, newBranchName) utils.Check(err) args.Replace(args.Executable, "checkout", newArgs...) } func showPr(command *Command, args *Args) { localRepo, err := github.LocalRepo() utils.Check(err) baseProject, err := localRepo.MainProject() utils.Check(err) host, err := github.CurrentConfig().PromptForHost(baseProject.Host) utils.Check(err) gh := github.NewClientWithHost(host) words := args.Words() openURL := "" prNumber := 0 var pr *github.PullRequest if len(words) > 0 { if prNumber, err = strconv.Atoi(words[0]); err == nil { openURL = baseProject.WebURL("", "", fmt.Sprintf("pull/%d", prNumber)) } else { utils.Check(fmt.Errorf("invalid pull request number: '%s'", words[0])) } } else { pr, err = findCurrentPullRequest(localRepo, gh, baseProject, args.Flag.Value("--head")) utils.Check(err) openURL = pr.HTMLURL } args.NoForward() if format := args.Flag.Value("--format"); format != "" { if pr == nil { pr, err = gh.PullRequest(baseProject, strconv.Itoa(prNumber)) utils.Check(err) } colorize := colorizeOutput(args.Flag.HasReceived("--color"), args.Flag.Value("--color")) ui.Println(formatPullRequest(*pr, format, colorize)) return } printURL := args.Flag.Bool("--url") copyURL := args.Flag.Bool("--copy") printBrowseOrCopy(args, openURL, !printURL && !copyURL, copyURL) } func findCurrentPullRequest(localRepo *github.GitHubRepo, gh *github.Client, baseProject *github.Project, headArg string) (*github.PullRequest, error) { filterParams := map[string]interface{}{ "state": "open", } headWithOwner := "" if headArg != "" { headWithOwner = headArg if !strings.Contains(headWithOwner, ":") { headWithOwner = fmt.Sprintf("%s:%s", baseProject.Owner, headWithOwner) } } else { currentBranch, err := localRepo.CurrentBranch() utils.Check(err) if headBranch, headProject, err := findPushTarget(currentBranch); err == nil { headWithOwner = fmt.Sprintf("%s:%s", headProject.Owner, headBranch.ShortName()) } else if headProject, err := deducePushTarget(currentBranch, gh.Host.User); err == nil { headWithOwner = fmt.Sprintf("%s:%s", headProject.Owner, currentBranch.ShortName()) } else { headWithOwner = fmt.Sprintf("%s:%s", baseProject.Owner, currentBranch.ShortName()) } } filterParams["head"] = headWithOwner pulls, err := gh.FetchPullRequests(baseProject, filterParams, 1, nil) if err != nil { return nil, err } else if len(pulls) == 1 { return &pulls[0], nil } else { return nil, fmt.Errorf("no open pull requests found for branch '%s'", headWithOwner) } } func branchTrackingInformation(branch *github.Branch) (string, *github.Branch, error) { branchRemote, err := git.Config(fmt.Sprintf("branch.%s.remote", branch.ShortName())) if branchRemote == "." { err = fmt.Errorf("branch is tracking another local branch") } if err != nil { return "", nil, err } branchMerge, err := git.Config(fmt.Sprintf("branch.%s.merge", branch.ShortName())) if err != nil { return "", nil, err } trackingBranch := &github.Branch{ Repo: branch.Repo, Name: branchMerge, } return branchRemote, trackingBranch, nil } func findPushTarget(branch *github.Branch) (*github.Branch, *github.Project, error) { branchRemote, headBranch, err := branchTrackingInformation(branch) if err != nil { return nil, nil, err } if headRemote, err := branch.Repo.RemoteByName(branchRemote); err == nil { headProject, err := headRemote.Project() if err != nil { return nil, nil, err } return headBranch, headProject, nil } remoteURL, err := git.ParseURL(branchRemote) if err != nil { return nil, nil, err } headProject, err := github.NewProjectFromURL(remoteURL) if err != nil { return nil, nil, err } return headBranch, headProject, nil } func deducePushTarget(branch *github.Branch, owner string) (*github.Project, error) { remote := branch.Repo.RemoteForBranch(branch, owner) if remote == nil { return nil, fmt.Errorf("no remote found for branch %s", branch.ShortName()) } return remote.Project() } func mergePr(command *Command, args *Args) { words := args.Words() if len(words) == 0 { utils.Check(fmt.Errorf("Error: No pull request number given")) } prNumber, err := strconv.Atoi(words[0]) utils.Check(err) params := map[string]interface{}{ "merge_method": "merge", } if args.Flag.Bool("--squash") { params["merge_method"] = "squash" } if args.Flag.Bool("--rebase") { params["merge_method"] = "rebase" } msgs := args.Flag.AllValues("--message") if len(msgs) > 0 { params["commit_title"] = msgs[0] params["commit_message"] = strings.Join(msgs[1:], "\n\n") } else if args.Flag.HasReceived("--file") { content, err := msgFromFile(args.Flag.Value("--file")) utils.Check(err) params["commit_title"], params["commit_message"] = github.SplitTitleBody(content) } if headSHA := args.Flag.Value("--head-sha"); headSHA != "" { params["sha"] = args.Flag.Value("--head-sha") } localRepo, err := github.LocalRepo() utils.Check(err) project, err := localRepo.MainProject() utils.Check(err) args.NoForward() if args.Noop { ui.Printf("Would merge pull request #%d for %s\n", prNumber, project) return } gh := github.NewClient(project.Host) _, err = gh.MergePullRequest(project, prNumber, params) utils.Check(err) if !args.Flag.Bool("--delete-branch") { return } pr, err := gh.PullRequest(project, strconv.Itoa(prNumber)) utils.Check(err) if !pr.IsSameRepo() { return } branchName := pr.Head.Ref err = gh.DeleteBranch(project, branchName) utils.Check(err) } func formatPullRequest(pr github.PullRequest, format string, colorize bool) string { placeholders := formatIssuePlaceholders(github.Issue(pr), colorize) delete(placeholders, "NC") delete(placeholders, "Nc") for key, value := range formatPullRequestPlaceholders(pr, colorize) { placeholders[key] = value } return ui.Expand(format, placeholders, colorize) } ================================================ FILE: commands/pull_request.go ================================================ package commands import ( "fmt" "os" "regexp" "strconv" "strings" "time" "github.com/github/hub/v2/git" "github.com/github/hub/v2/github" "github.com/github/hub/v2/utils" ) var cmdPullRequest = &Command{ Run: pullRequest, Usage: ` pull-request [-focpd] [-b ] [-h ] [-r ] [-a ] [-M ] [-l ] pull-request -m [--edit] pull-request -F [--edit] pull-request -i `, Long: `Create a GitHub Pull Request. ## Options: -f, --force Skip the check for unpushed commits. -m, --message The text up to the first blank line in is treated as the pull request title, and the rest is used as pull request description in Markdown format. When multiple ''--message'' are passed, their values are concatenated with a blank line in-between. When neither ''--message'' nor ''--file'' were supplied, a text editor will open to author the title and description in. --no-edit Use the message from the first commit on the branch as pull request title and description without opening a text editor. -F, --file Read the pull request title and description from . Pass "-" to read from standard input instead. See ''--message'' for the formatting rules. -e, --edit Open the pull request title and description in a text editor before submitting. This can be used in combination with ''--message'' or ''--file''. -i, --issue Convert (referenced by its number) to a pull request. You can only convert issues authored by you or that which you have admin rights over. In most workflows it is not necessary to convert issues to pull requests; you can simply reference the original issue in the body of the new pull request. -o, --browse Open the new pull request in a web browser. -c, --copy Put the URL of the new pull request to clipboard instead of printing it. -p, --push Push the current branch to before creating the pull request. -b, --base The base branch in the "[:]" format. Defaults to the default branch of the upstream repository (usually "master"). See the "CONVENTIONS" section of hub(1) for more information on how hub selects the defaults in case of multiple git remotes. -h, --head The head branch in "[:]" format. Defaults to the currently checked out branch. -r, --reviewer A comma-separated list (no spaces around the comma) of GitHub handles to request a review from. -a, --assign A comma-separated list (no spaces around the comma) of GitHub handles to assign to this pull request. -M, --milestone The milestone name to add to this pull request. Passing the milestone number is deprecated. -l, --labels A comma-separated list (no spaces around the comma) of labels to add to this pull request. Labels will be created if they do not already exist. -d, --draft Create the pull request as a draft. --no-maintainer-edits When creating a pull request from a fork, this disallows projects maintainers from being able to push to the head branch of this fork. Maintainer edits are allowed by default. ## Examples: $ hub pull-request [ opens a text editor for writing title and message ] [ creates a pull request for the current branch ] $ hub pull-request --base OWNER:master --head MYUSER:my-branch [ creates a pull request with explicit base and head branches ] $ hub pull-request --browse -m "My title" [ creates a pull request with the given title and opens it in a browser ] $ hub pull-request -F - --edit < path/to/message-template.md [ further edit the title and message received on standard input ] ## Configuration: * ''HUB_RETRY_TIMEOUT'': The maximum time to keep retrying after HTTP 422 on ''--push'' (default: 9). ## See also: hub(1), hub-merge(1), hub-checkout(1) `, } func init() { CmdRunner.Use(cmdPullRequest) } func pullRequest(cmd *Command, args *Args) { localRepo, err := github.LocalRepo() utils.Check(err) currentBranch, currentBranchErr := localRepo.CurrentBranch() baseProject, err := localRepo.MainProject() utils.Check(err) host, err := github.CurrentConfig().PromptForHost(baseProject.Host) if err != nil { utils.Check(github.FormatError("creating pull request", err)) } client := github.NewClientWithHost(host) trackedBranch, headProject, _ := localRepo.RemoteBranchAndProject(host.User, false) if headProject == nil { utils.Check(fmt.Errorf("could not determine project for head branch")) } var ( base, head string ) if flagPullRequestBase := args.Flag.Value("--base"); flagPullRequestBase != "" { baseProject, base = parsePullRequestProject(baseProject, flagPullRequestBase) } if flagPullRequestHead := args.Flag.Value("--head"); flagPullRequestHead != "" { headProject, head = parsePullRequestProject(headProject, flagPullRequestHead) } baseRemote, _ := localRepo.RemoteForProject(baseProject) if base == "" && baseRemote != nil { base = localRepo.DefaultBranch(baseRemote).ShortName() } if head == "" && trackedBranch != nil { if !trackedBranch.IsRemote() { // the current branch tracking another branch // pretend there's no upstream at all trackedBranch = nil } else { if baseProject.SameAs(headProject) && base == trackedBranch.ShortName() { e := fmt.Errorf(`Aborted: head branch is the same as base ("%s")`, base) e = fmt.Errorf("%s\n(use `-h ` to specify an explicit pull request head)", e) utils.Check(e) } } } force := args.Flag.Bool("--force") flagPullRequestPush := args.Flag.Bool("--push") if head == "" { if trackedBranch == nil { utils.Check(currentBranchErr) if !force && !flagPullRequestPush { branchRemote, branchMerge, err := branchTrackingInformation(currentBranch) if err != nil || (baseRemote != nil && branchRemote == baseRemote.Name && branchMerge.ShortName() == base) { if localRepo.RemoteForBranch(currentBranch, host.User) == nil { err = fmt.Errorf("Aborted: the current branch seems not yet pushed to a remote") err = fmt.Errorf("%s\n(use `-p` to push the branch or `-f` to skip this check)", err) utils.Check(err) } } } head = currentBranch.ShortName() } else { head = trackedBranch.ShortName() } } if headRepo, err := client.Repository(headProject); err == nil { headProject.Owner = headRepo.Owner.Login headProject.Name = headRepo.Name } fullBase := fmt.Sprintf("%s:%s", baseProject.Owner, base) fullHead := fmt.Sprintf("%s:%s", headProject.Owner, head) if !force && trackedBranch != nil { remoteCommits, err := git.RefList(trackedBranch.LongName(), "") if err == nil && len(remoteCommits) > 0 { err = fmt.Errorf("Aborted: %d commits are not yet pushed to %s", len(remoteCommits), trackedBranch.LongName()) err = fmt.Errorf("%s\n(use `-f` to force submit a pull request anyway)", err) utils.Check(err) } } messageBuilder := &github.MessageBuilder{ Filename: "PULLREQ_EDITMSG", Title: "pull request", } baseTracking := base headTracking := head remote := baseRemote if remote != nil { baseTracking = fmt.Sprintf("%s/%s", remote.Name, base) } if remote == nil || !baseProject.SameAs(headProject) { remote, _ = localRepo.RemoteForProject(headProject) } if remote != nil { headTracking = fmt.Sprintf("%s/%s", remote.Name, head) } if flagPullRequestPush && remote == nil { utils.Check(fmt.Errorf("Can't find remote for %s", head)) } messageBuilder.AddCommentedSection(fmt.Sprintf(`Requesting a pull to %s from %s Write a message for this pull request. The first block of text is the title and the rest is the description.`, fullBase, fullHead)) flagPullRequestMessage := args.Flag.AllValues("--message") flagPullRequestEdit := args.Flag.Bool("--edit") flagPullRequestIssue := args.Flag.Value("--issue") if !args.Flag.HasReceived("--issue") && args.ParamsSize() > 0 { flagPullRequestIssue = parsePullRequestIssueNumber(args.GetParam(0)) } if len(flagPullRequestMessage) > 0 { messageBuilder.Message = strings.Join(flagPullRequestMessage, "\n\n") messageBuilder.Edit = flagPullRequestEdit } else if args.Flag.HasReceived("--file") { messageBuilder.Message, err = msgFromFile(args.Flag.Value("--file")) utils.Check(err) messageBuilder.Edit = flagPullRequestEdit } else if args.Flag.Bool("--no-edit") { commits, _ := git.RefList(baseTracking, head) if len(commits) == 0 { utils.Check(fmt.Errorf("Aborted: no commits detected between %s and %s", baseTracking, head)) } message, err := git.Show(commits[len(commits)-1]) utils.Check(err) messageBuilder.Message = message } else if flagPullRequestIssue == "" { messageBuilder.Edit = true headForMessage := headTracking if flagPullRequestPush { headForMessage = head } message := "" commits, _ := git.RefList(baseTracking, headForMessage) if len(commits) == 1 { message, err = git.Show(commits[0]) utils.Check(err) re := regexp.MustCompile(`\n(Co-authored-by|Signed-off-by):[^\n]+`) message = re.ReplaceAllString(message, "") } else if len(commits) > 1 { commitLogs, err := git.Log(baseTracking, headForMessage) utils.Check(err) if commitLogs != "" { messageBuilder.AddCommentedSection("\nChanges:\n\n" + strings.TrimSpace(commitLogs)) } } workdir, _ := git.WorkdirName() if workdir != "" { template, _ := github.ReadTemplate(github.PullRequestTemplate, workdir) if template != "" { message = message + "\n\n\n" + template } } messageBuilder.Message = message } title, body, err := messageBuilder.Extract() utils.Check(err) if title == "" && flagPullRequestIssue == "" { utils.Check(fmt.Errorf("Aborting due to empty pull request title")) } if flagPullRequestPush { if args.Noop { args.Before(fmt.Sprintf("Would push to %s/%s", remote.Name, head), "") } else { err = git.Spawn("push", "--set-upstream", remote.Name, fmt.Sprintf("HEAD:%s", head)) utils.Check(err) } } milestoneNumber, err := milestoneValueToNumber(args.Flag.Value("--milestone"), client, baseProject) utils.Check(err) var pullRequestURL string if args.Noop { args.Before(fmt.Sprintf("Would request a pull request to %s from %s", fullBase, fullHead), "") pullRequestURL = "PULL_REQUEST_URL" } else { params := map[string]interface{}{ "base": base, "head": fullHead, "maintainer_can_modify": !args.Flag.Bool("--no-maintainer-edits"), } if args.Flag.Bool("--draft") { params["draft"] = true } if title != "" { params["title"] = title if body != "" { params["body"] = body } } else { issueNum, _ := strconv.Atoi(flagPullRequestIssue) params["issue"] = issueNum } startedAt := time.Now() numRetries := 0 retryDelay := 2 retryAllowance := 0 if flagPullRequestPush { if allowanceFromEnv := os.Getenv("HUB_RETRY_TIMEOUT"); allowanceFromEnv != "" { retryAllowance, err = strconv.Atoi(allowanceFromEnv) utils.Check(err) } else { retryAllowance = 9 } } var pr *github.PullRequest for { pr, err = client.CreatePullRequest(baseProject, params) if err != nil && strings.Contains(err.Error(), `Invalid value for "head"`) { if retryAllowance > 0 { retryAllowance -= retryDelay time.Sleep(time.Duration(retryDelay) * time.Second) retryDelay++ numRetries++ } else { if numRetries > 0 { duration := time.Since(startedAt) err = fmt.Errorf("%s\nGiven up after retrying for %.1f seconds.", err, duration.Seconds()) } break } } else { break } } if err == nil { defer messageBuilder.Cleanup() } utils.Check(err) pullRequestURL = pr.HTMLURL params = map[string]interface{}{} flagPullRequestLabels := commaSeparated(args.Flag.AllValues("--labels")) if len(flagPullRequestLabels) > 0 { params["labels"] = flagPullRequestLabels } flagPullRequestAssignees := commaSeparated(args.Flag.AllValues("--assign")) if len(flagPullRequestAssignees) > 0 { params["assignees"] = flagPullRequestAssignees } if milestoneNumber > 0 { params["milestone"] = milestoneNumber } if len(params) > 0 { err = client.UpdateIssue(baseProject, pr.Number, params) utils.Check(err) } flagPullRequestReviewers := commaSeparated(args.Flag.AllValues("--reviewer")) if len(flagPullRequestReviewers) > 0 { userReviewers := []string{} teamReviewers := []string{} for _, reviewer := range flagPullRequestReviewers { if strings.Contains(reviewer, "/") { teamName := strings.SplitN(reviewer, "/", 2)[1] if !pr.HasRequestedTeam(teamName) { teamReviewers = append(teamReviewers, teamName) } } else if !pr.HasRequestedReviewer(reviewer) { userReviewers = append(userReviewers, reviewer) } } if len(userReviewers) > 0 || len(teamReviewers) > 0 { err = client.RequestReview(baseProject, pr.Number, map[string]interface{}{ "reviewers": userReviewers, "team_reviewers": teamReviewers, }) utils.Check(err) } } } args.NoForward() printBrowseOrCopy(args, pullRequestURL, args.Flag.Bool("--browse"), args.Flag.Bool("--copy")) } func parsePullRequestProject(context *github.Project, s string) (p *github.Project, ref string) { p = context ref = s if strings.Contains(s, ":") { split := strings.SplitN(s, ":", 2) ref = split[1] var name string if !strings.Contains(split[0], "/") { name = context.Name } p = github.NewProject(split[0], name, context.Host) } return } func parsePullRequestIssueNumber(url string) string { u, e := github.ParseURL(url) if e != nil { return "" } r := regexp.MustCompile(`^issues\/(\d+)`) p := u.ProjectPath() if r.MatchString(p) { return r.FindStringSubmatch(p)[1] } return "" } func commaSeparated(l []string) []string { res := []string{} for _, i := range l { if i == "" { continue } res = append(res, strings.Split(i, ",")...) } return res } ================================================ FILE: commands/pull_request_test.go ================================================ package commands import ( "testing" "github.com/github/hub/v2/github" "github.com/github/hub/v2/internal/assert" ) func TestPullRequest_ParsePullRequestProject(t *testing.T) { c := &github.Project{Host: "github.com", Owner: "jingweno", Name: "gh"} s := "develop" p, ref := parsePullRequestProject(c, s) assert.Equal(t, "develop", ref) assert.Equal(t, "github.com", p.Host) assert.Equal(t, "jingweno", p.Owner) assert.Equal(t, "gh", p.Name) s = "mojombo:develop" p, ref = parsePullRequestProject(c, s) assert.Equal(t, "develop", ref) assert.Equal(t, "github.com", p.Host) assert.Equal(t, "mojombo", p.Owner) assert.Equal(t, "gh", p.Name) s = "mojombo/jekyll:develop" p, ref = parsePullRequestProject(c, s) assert.Equal(t, "develop", ref) assert.Equal(t, "github.com", p.Host) assert.Equal(t, "mojombo", p.Owner) assert.Equal(t, "jekyll", p.Name) } ================================================ FILE: commands/push.go ================================================ package commands import ( "strings" "github.com/github/hub/v2/github" "github.com/github/hub/v2/utils" ) var cmdPush = &Command{ Run: push, GitExtension: true, Usage: "push [,...] []", Long: `Push a git branch to each of the listed remotes. ## Examples: $ hub push origin,staging,qa bert_timeout > git push origin bert_timeout > git push staging bert_timeout > git push qa bert_timeout $ hub push origin > git push origin HEAD ## See also: hub(1), git-push(1) `, } func init() { CmdRunner.Use(cmdPush) } func push(command *Command, args *Args) { if !args.IsParamsEmpty() && strings.Contains(args.FirstParam(), ",") { transformPushArgs(args) } } func transformPushArgs(args *Args) { refs := []string{} if args.ParamsSize() > 1 { refs = args.Params[1:] } remotes := strings.Split(args.FirstParam(), ",") args.ReplaceParam(0, remotes[0]) if len(refs) == 0 { localRepo, err := github.LocalRepo() utils.Check(err) head, err := localRepo.CurrentBranch() utils.Check(err) refs = []string{head.ShortName()} args.AppendParams(refs...) } for _, remote := range remotes[1:] { afterCmd := []string{"git", "push", remote} afterCmd = append(afterCmd, refs...) args.After(afterCmd...) } } ================================================ FILE: commands/push_test.go ================================================ package commands import ( "github.com/github/hub/v2/internal/assert" "testing" ) func TestTransformPushArgs(t *testing.T) { args := NewArgs([]string{"push", "origin,staging,qa", "bert_timeout"}) transformPushArgs(args) cmds := args.Commands() assert.Equal(t, 3, len(cmds)) assert.Equal(t, "git push origin bert_timeout", cmds[0].String()) assert.Equal(t, "git push staging bert_timeout", cmds[1].String()) // TODO: travis-ci doesn't have HEAD //args = NewArgs([]string{"push", "origin"}) //transformPushArgs(args) //cmds = args.Commands() //assert.Equal(t, 1, len(cmds)) //pushRegexp := regexp.MustCompile("git push origin .+") //assert.T(t, pushRegexp.MatchString(cmds[0].String())) } ================================================ FILE: commands/release.go ================================================ package commands import ( "fmt" "io" "os" "path/filepath" "strings" "time" "github.com/github/hub/v2/github" "github.com/github/hub/v2/ui" "github.com/github/hub/v2/utils" ) var ( cmdRelease = &Command{ Run: listReleases, Usage: ` release [--include-drafts] [--exclude-prereleases] [-L ] [-f ] release show [-f ] release create [-dpoc] [-a ] [-m |-F ] [-t ] release edit [] release download [-i ] release delete `, Long: `Manage GitHub Releases for the current repository. ## Commands: With no arguments, shows a list of existing releases. * _show_: Show GitHub release notes for . With ''--show-downloads'', include the "Downloads" section. * _create_: Create a GitHub release for the specified name. If git tag does not exist, it will be created at (default: current branch). * _edit_: Edit the GitHub release for the specified name. Accepts the same options as _create_ command. Publish a draft with ''--draft=false''. Without ''--message'' or ''--file'', a text editor will open pre-populated with the current release title and body. To re-use existing title and body unchanged, pass ''-m ""''. * _download_: Download the assets attached to release for the specified . * _delete_: Delete the release and associated assets for the specified . Note that this does **not** remove the git tag . ## Options: -d, --include-drafts List drafts together with published releases. -p, --exclude-prereleases Exclude prereleases from the list. -L, --limit Display only the first releases. -d, --draft Create a draft release. -p, --prerelease Create a pre-release. -a, --attach Attach a file as an asset for this release. If is in the "#" format, the text after the "#" character is taken as asset label. -m, --message The text up to the first blank line in is treated as the release title, and the rest is used as release description in Markdown format. When multiple ''--message'' are passed, their values are concatenated with a blank line in-between. When neither ''--message'' nor ''--file'' were supplied to ''release create'', a text editor will open to author the title and description in. -F, --file Read the release title and description from . Pass "-" to read from standard input instead. See ''--message'' for the formatting rules. -e, --edit Open the release title and description in a text editor before submitting. This can be used in combination with ''--message'' or ''--file''. -o, --browse Open the new release in a web browser. -c, --copy Put the URL of the new release to clipboard instead of printing it. -t, --commitish A commit SHA or branch name to attach the release to, only used if does not already exist (default: main branch). -i, --include Filter the files in the release to those that match the glob . -f, --format Pretty print releases using (default: "%T%n"). See the "PRETTY FORMATS" section of git-log(1) for some additional details on how placeholders are used in format. The available placeholders for issues are: %U: the URL of this release %uT: tarball URL %uZ: zipball URL %uA: asset upload URL %S: state (i.e. "draft", "pre-release") %sC: set color to yellow or red, depending on state %t: release name %T: release tag %b: body %as: the list of assets attached to this release %cD: created date-only (no time of day) %cr: created date, relative %ct: created date, UNIX timestamp %cI: created date, ISO 8601 format %pD: published date-only (no time of day) %pr: published date, relative %pt: published date, UNIX timestamp %pI: published date, ISO 8601 format %n: newline %%: a literal % --color[=] Enable colored output even if stdout is not a terminal. can be one of "always" (default for ''--color''), "never", or "auto" (default). The git tag name for this release. ## See also: hub(1), git-tag(1) `, KnownFlags: ` -d, --include-drafts -p, --exclude-prereleases -L, --limit N -f, --format FMT --color `, } cmdShowRelease = &Command{ Key: "show", Run: showRelease, KnownFlags: ` -d, --show-downloads -f, --format FMT --color `, } cmdCreateRelease = &Command{ Key: "create", Run: createRelease, KnownFlags: ` -e, --edit -d, --draft -p, --prerelease -o, --browse -c, --copy -a, --attach FILE -m, --message MSG -F, --file FILE -t, --commitish C `, } cmdEditRelease = &Command{ Key: "edit", Run: editRelease, KnownFlags: ` -e, --edit -d, --draft -p, --prerelease -a, --attach FILE -m, --message MSG -F, --file FILE -t, --commitish C `, } cmdDownloadRelease = &Command{ Key: "download", Run: downloadRelease, KnownFlags: ` -i, --include PATTERN `, } cmdDeleteRelease = &Command{ Key: "delete", Run: deleteRelease, } ) func init() { cmdRelease.Use(cmdShowRelease) cmdRelease.Use(cmdCreateRelease) cmdRelease.Use(cmdEditRelease) cmdRelease.Use(cmdDownloadRelease) cmdRelease.Use(cmdDeleteRelease) CmdRunner.Use(cmdRelease) } func listReleases(cmd *Command, args *Args) { localRepo, err := github.LocalRepo() utils.Check(err) project, err := localRepo.MainProject() utils.Check(err) gh := github.NewClient(project.Host) flagReleaseLimit := args.Flag.Int("--limit") flagReleaseIncludeDrafts := args.Flag.Bool("--include-drafts") flagReleaseExcludePrereleases := args.Flag.Bool("--exclude-prereleases") if args.Noop { ui.Printf("Would request list of releases for %s\n", project) } else { releases, err := gh.FetchReleases(project, flagReleaseLimit, func(release *github.Release) bool { return (!release.Draft || flagReleaseIncludeDrafts) && (!release.Prerelease || !flagReleaseExcludePrereleases) }) utils.Check(err) colorize := colorizeOutput(args.Flag.HasReceived("--color"), args.Flag.Value("--color")) for _, release := range releases { flagReleaseFormat := "%T%n" if args.Flag.HasReceived("--format") { flagReleaseFormat = args.Flag.Value("--format") } ui.Print(formatRelease(release, flagReleaseFormat, colorize)) } } args.NoForward() } func formatRelease(release github.Release, format string, colorize bool) string { state := "" stateColorSwitch := "" if release.Draft { state = "draft" stateColorSwitch = fmt.Sprintf("\033[%dm", 33) } else if release.Prerelease { state = "pre-release" stateColorSwitch = fmt.Sprintf("\033[%dm", 31) } var createdDate, createdAtISO8601, createdAtUnix, createdAtRelative, publishedDate, publishedAtISO8601, publishedAtUnix, publishedAtRelative string if !release.CreatedAt.IsZero() { createdDate = release.CreatedAt.Format("02 Jan 2006") createdAtISO8601 = release.CreatedAt.Format(time.RFC3339) createdAtUnix = fmt.Sprintf("%d", release.CreatedAt.Unix()) createdAtRelative = utils.TimeAgo(release.CreatedAt) } if !release.PublishedAt.IsZero() { publishedDate = release.PublishedAt.Format("02 Jan 2006") publishedAtISO8601 = release.PublishedAt.Format(time.RFC3339) publishedAtUnix = fmt.Sprintf("%d", release.PublishedAt.Unix()) publishedAtRelative = utils.TimeAgo(release.PublishedAt) } assets := make([]string, len(release.Assets)) for i, asset := range release.Assets { assets[i] = fmt.Sprintf("%s\t%s", asset.DownloadURL, asset.Label) } placeholders := map[string]string{ "U": release.HTMLURL, "uT": release.TarballURL, "uZ": release.ZipballURL, "uA": release.UploadURL, "S": state, "sC": stateColorSwitch, "t": release.Name, "T": release.TagName, "b": release.Body, "as": strings.Join(assets, "\n"), "cD": createdDate, "cI": createdAtISO8601, "ct": createdAtUnix, "cr": createdAtRelative, "pD": publishedDate, "pI": publishedAtISO8601, "pt": publishedAtUnix, "pr": publishedAtRelative, } return ui.Expand(format, placeholders, colorize) } func showRelease(cmd *Command, args *Args) { tagName := "" if args.ParamsSize() > 0 { tagName = args.GetParam(0) } if tagName == "" { utils.Check(cmd.UsageError("")) } localRepo, err := github.LocalRepo() utils.Check(err) project, err := localRepo.MainProject() utils.Check(err) gh := github.NewClient(project.Host) args.NoForward() if args.Noop { ui.Printf("Would display information for `%s' release\n", tagName) } else { release, err := gh.FetchRelease(project, tagName) utils.Check(err) body := strings.TrimSpace(release.Body) colorize := colorizeOutput(args.Flag.HasReceived("--color"), args.Flag.Value("--color")) if flagShowReleaseFormat := args.Flag.Value("--format"); flagShowReleaseFormat != "" { ui.Print(formatRelease(*release, flagShowReleaseFormat, colorize)) return } ui.Println(release.Name) if body != "" { ui.Printf("\n%s\n", body) } if args.Flag.Bool("--show-downloads") { ui.Printf("\n## Downloads\n\n") for _, asset := range release.Assets { ui.Println(asset.DownloadURL) } if release.ZipballURL != "" { ui.Println(release.ZipballURL) ui.Println(release.TarballURL) } } } } func downloadRelease(cmd *Command, args *Args) { tagName := "" if args.ParamsSize() > 0 { tagName = args.GetParam(0) } if tagName == "" { utils.Check(cmd.UsageError("")) } localRepo, err := github.LocalRepo() utils.Check(err) project, err := localRepo.MainProject() utils.Check(err) gh := github.NewClient(project.Host) release, err := gh.FetchRelease(project, tagName) utils.Check(err) hasPattern := args.Flag.HasReceived("--include") found := false for _, asset := range release.Assets { if hasPattern { isMatch, err := filepath.Match(args.Flag.Value("--include"), asset.Name) utils.Check(err) if !isMatch { continue } } found = true ui.Printf("Downloading %s ...\n", asset.Name) err := downloadReleaseAsset(asset, gh) utils.Check(err) } if !found && hasPattern { names := []string{} for _, asset := range release.Assets { names = append(names, asset.Name) } utils.Check(fmt.Errorf("the `--include` pattern did not match any available assets:\n%s", strings.Join(names, "\n"))) } args.NoForward() } func downloadReleaseAsset(asset github.ReleaseAsset, gh *github.Client) (err error) { assetReader, err := gh.DownloadReleaseAsset(asset.APIURL) if err != nil { return } defer assetReader.Close() assetFile, err := os.OpenFile(asset.Name, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644) if err != nil { return } defer assetFile.Close() _, err = io.Copy(assetFile, assetReader) if err != nil { return } return } func createRelease(cmd *Command, args *Args) { tagName := "" if args.ParamsSize() > 0 { tagName = args.GetParam(0) } if tagName == "" { utils.Check(cmd.UsageError("")) return } assetsToUpload, close, err := openAssetFiles(args.Flag.AllValues("--attach")) utils.Check(err) defer close() localRepo, err := github.LocalRepo() utils.Check(err) project, err := localRepo.MainProject() utils.Check(err) gh := github.NewClient(project.Host) messageBuilder := &github.MessageBuilder{ Filename: "RELEASE_EDITMSG", Title: "release", } messageBuilder.AddCommentedSection(fmt.Sprintf(`Creating release %s for %s Write a message for this release. The first block of text is the title and the rest is the description.`, tagName, project)) flagReleaseMessage := args.Flag.AllValues("--message") if len(flagReleaseMessage) > 0 { messageBuilder.Message = strings.Join(flagReleaseMessage, "\n\n") messageBuilder.Edit = args.Flag.Bool("--edit") } else if args.Flag.HasReceived("--file") { messageBuilder.Message, err = msgFromFile(args.Flag.Value("--file")) utils.Check(err) messageBuilder.Edit = args.Flag.Bool("--edit") } else { messageBuilder.Edit = true } title, body, err := messageBuilder.Extract() utils.Check(err) if title == "" { utils.Check(fmt.Errorf("Aborting release due to empty release title")) } params := &github.Release{ TagName: tagName, TargetCommitish: args.Flag.Value("--commitish"), Name: title, Body: body, Draft: args.Flag.Bool("--draft"), Prerelease: args.Flag.Bool("--prerelease"), } var release *github.Release args.NoForward() if args.Noop { ui.Printf("Would create release `%s' for %s with tag name `%s'\n", title, project, tagName) } else { release, err = gh.CreateRelease(project, params) utils.Check(err) flagReleaseBrowse := args.Flag.Bool("--browse") flagReleaseCopy := args.Flag.Bool("--copy") printBrowseOrCopy(args, release.HTMLURL, flagReleaseBrowse, flagReleaseCopy) } messageBuilder.Cleanup() numAssets := len(assetsToUpload) if numAssets == 0 { return } if args.Noop { ui.Printf("Would attach %d %s\n", numAssets, pluralize(numAssets, "asset")) } else { ui.Errorf("Attaching %d %s...\n", numAssets, pluralize(numAssets, "asset")) uploaded, err := gh.UploadReleaseAssets(release, assetsToUpload) if err != nil { failed := []string{} for _, a := range assetsToUpload[len(uploaded):] { failed = append(failed, fmt.Sprintf("-a %s", a.Name)) } ui.Errorf("The release was created, but attaching %d %s failed. ", len(failed), pluralize(len(failed), "asset")) ui.Errorf("You can retry with:\n%s release edit %s -m '' %s\n\n", "hub", release.TagName, strings.Join(failed, " ")) utils.Check(err) } } } func editRelease(cmd *Command, args *Args) { tagName := "" if args.ParamsSize() > 0 { tagName = args.GetParam(0) } if tagName == "" { utils.Check(cmd.UsageError("")) return } assetsToUpload, close, err := openAssetFiles(args.Flag.AllValues("--attach")) utils.Check(err) defer close() localRepo, err := github.LocalRepo() utils.Check(err) project, err := localRepo.MainProject() utils.Check(err) gh := github.NewClient(project.Host) release, err := gh.FetchRelease(project, tagName) utils.Check(err) params := map[string]interface{}{} if args.Flag.HasReceived("--commitish") { params["target_commitish"] = args.Flag.Value("--commitish") } if args.Flag.HasReceived("--draft") { params["draft"] = args.Flag.Bool("--draft") } if args.Flag.HasReceived("--prerelease") { params["prerelease"] = args.Flag.Bool("--prerelease") } messageBuilder := &github.MessageBuilder{ Filename: "RELEASE_EDITMSG", Title: "release", } messageBuilder.AddCommentedSection(fmt.Sprintf(`Editing release %s for %s Write a message for this release. The first block of text is the title and the rest is the description.`, tagName, project)) flagReleaseMessage := args.Flag.AllValues("--message") if len(flagReleaseMessage) > 0 { messageBuilder.Message = strings.Join(flagReleaseMessage, "\n\n") messageBuilder.Edit = args.Flag.Bool("--edit") } else if args.Flag.HasReceived("--file") { messageBuilder.Message, err = msgFromFile(args.Flag.Value("--file")) utils.Check(err) messageBuilder.Edit = args.Flag.Bool("--edit") } else { messageBuilder.Edit = true messageBuilder.Message = strings.Replace(fmt.Sprintf("%s\n\n%s", release.Name, release.Body), "\r\n", "\n", -1) } title, body, err := messageBuilder.Extract() utils.Check(err) if title == "" && len(flagReleaseMessage) == 0 { utils.Check(fmt.Errorf("Aborting editing due to empty release title")) } if title != "" { params["name"] = title } if body != "" { params["body"] = body } args.NoForward() if len(params) > 0 { if args.Noop { ui.Printf("Would edit release `%s'\n", tagName) } else { release, err = gh.EditRelease(release, params) utils.Check(err) } messageBuilder.Cleanup() } numAssets := len(assetsToUpload) if numAssets == 0 { return } if args.Noop { ui.Printf("Would attach %d %s\n", numAssets, pluralize(numAssets, "asset")) } else { ui.Errorf("Attaching %d %s...\n", numAssets, pluralize(numAssets, "asset")) uploaded, err := gh.UploadReleaseAssets(release, assetsToUpload) if err != nil { failed := []string{} for _, a := range assetsToUpload[len(uploaded):] { failed = append(failed, a.Name) } ui.Errorf("Attaching these assets failed:\n%s\n\n", strings.Join(failed, "\n")) utils.Check(err) } } } func deleteRelease(cmd *Command, args *Args) { tagName := "" if args.ParamsSize() > 0 { tagName = args.GetParam(0) } if tagName == "" { utils.Check(cmd.UsageError("")) return } localRepo, err := github.LocalRepo() utils.Check(err) project, err := localRepo.MainProject() utils.Check(err) gh := github.NewClient(project.Host) release, err := gh.FetchRelease(project, tagName) utils.Check(err) if args.Noop { message := fmt.Sprintf("Deleting release related to %s...", tagName) ui.Println(message) } else { err = gh.DeleteRelease(release) utils.Check(err) } args.NoForward() } func openAssetFiles(args []string) ([]github.LocalAsset, func(), error) { assets := []github.LocalAsset{} files := []*os.File{} for _, arg := range args { var label string parts := strings.SplitN(arg, "#", 2) path := parts[0] if len(parts) > 1 { label = parts[1] } file, err := os.Open(path) if err != nil { return nil, nil, err } stat, err := file.Stat() if err != nil { return nil, nil, err } files = append(files, file) assets = append(assets, github.LocalAsset{ Name: path, Label: label, Size: stat.Size(), Contents: file, }) } close := func() { for _, f := range files { f.Close() } } return assets, close, nil } func pluralize(count int, label string) string { if count == 1 { return label } return fmt.Sprintf("%ss", label) } ================================================ FILE: commands/remote.go ================================================ package commands import ( "fmt" "regexp" "strings" "github.com/github/hub/v2/git" "github.com/github/hub/v2/github" "github.com/github/hub/v2/utils" ) var cmdRemote = &Command{ Run: remote, GitExtension: true, Usage: ` remote add [-p] [] [/] remote set-url [-p] [] [/] `, Long: `Add a git remote for a GitHub repository. ## Options: -p (Deprecated) Use the ''ssh:'' protocol instead of ''git:'' for the remote URL. The writeable ''ssh:'' protocol is automatically used for own repos, GitHub Enterprise remotes, and private or pushable repositories. [/] If is "origin", that value will be substituted for your GitHub username. defaults to the name of the current working directory. ## Examples: $ hub remote add jingweno > git remote add jingweno git://github.com/jingweno/REPO.git $ hub remote add origin > git remote add origin git@github.com:USER/REPO.git ## See also: hub-fork(1), hub(1), git-remote(1) `, } func init() { CmdRunner.Use(cmdRemote) } /* */ func remote(command *Command, args *Args) { if !args.IsParamsEmpty() && (args.FirstParam() == "add" || args.FirstParam() == "set-url") { transformRemoteArgs(args) } } func transformRemoteArgs(args *Args) { ownerWithName := args.LastParam() re := regexp.MustCompile(fmt.Sprintf(`^%s(/%s)?$`, OwnerRe, NameRe)) if !re.MatchString(ownerWithName) { return } owner := ownerWithName name := "" if strings.Contains(ownerWithName, "/") { parts := strings.SplitN(ownerWithName, "/", 2) owner, name = parts[0], parts[1] } localRepo, err := github.LocalRepo() utils.Check(err) var host string mainProject, err := localRepo.MainProject() if err == nil { host = mainProject.Host } if name == "" { if mainProject != nil { name = mainProject.Name } else { dirName, err := git.WorkdirName() utils.Check(err) name = github.SanitizeProjectName(dirName) } } var hostConfig *github.Host if host == "" { hostConfig, err = github.CurrentConfig().DefaultHost() } else { hostConfig, err = github.CurrentConfig().PromptForHost(host) } if err != nil { utils.Check(github.FormatError("adding remote", err)) } host = hostConfig.Host p := utils.NewArgsParser() p.RegisterValue("-t") p.RegisterValue("-m") params, _ := p.Parse(args.Params) if len(params) > 3 { return } for i, pi := range p.PositionalIndices { if i == 1 && strings.Contains(params[i], "/") { args.ReplaceParam(pi, owner) } else if i == 2 { args.RemoveParam(pi) } } if len(params) == 2 && owner == "origin" { owner = hostConfig.User } if strings.EqualFold(owner, hostConfig.User) { owner = hostConfig.User } project := github.NewProject(owner, name, host) isPrivate := parseRemotePrivateFlag(args) || owner == hostConfig.User || project.Host != github.GitHubHost if !isPrivate { gh := github.NewClient(project.Host) repo, err := gh.Repository(project) if err != nil { if strings.Contains(err.Error(), "HTTP 404") { err = fmt.Errorf("Error: repository %s/%s doesn't exist", project.Owner, project.Name) } utils.Check(err) } isPrivate = repo.Private || repo.Permissions.Push } url := project.GitURL("", "", isPrivate) args.AppendParams(url) } func parseRemotePrivateFlag(args *Args) bool { if i := args.IndexOfParam("-p"); i != -1 { args.RemoveParam(i) return true } return false } ================================================ FILE: commands/remote_test.go ================================================ package commands import ( "os" "regexp" "testing" "github.com/github/hub/v2/fixtures" "github.com/github/hub/v2/github" "github.com/github/hub/v2/internal/assert" ) func TestTransformRemoteArgs(t *testing.T) { repo := fixtures.SetupTestRepo() defer repo.TearDown() os.Setenv("HUB_PROTOCOL", "git") github.CreateTestConfigs("jingweno", "123") args := NewArgs([]string{"remote", "add", "jingweno"}) transformRemoteArgs(args) assert.Equal(t, 3, args.ParamsSize()) assert.Equal(t, "add", args.FirstParam()) assert.Equal(t, "jingweno", args.GetParam(1)) reg := regexp.MustCompile("^git@github\\.com:jingweno/.+\\.git$") assert.T(t, reg.MatchString(args.GetParam(2))) args = NewArgs([]string{"remote", "add", "-p", "mislav"}) transformRemoteArgs(args) assert.Equal(t, 3, args.ParamsSize()) assert.Equal(t, "add", args.FirstParam()) assert.Equal(t, "mislav", args.GetParam(1)) reg = regexp.MustCompile("^git@github\\.com:mislav/.+\\.git$") assert.T(t, reg.MatchString(args.GetParam(2))) args = NewArgs([]string{"remote", "add", "origin"}) transformRemoteArgs(args) assert.Equal(t, 3, args.ParamsSize()) assert.Equal(t, "add", args.FirstParam()) assert.Equal(t, "origin", args.GetParam(1)) reg = regexp.MustCompile("^git@github\\.com:jingweno/.+\\.git$") assert.T(t, reg.MatchString(args.GetParam(2))) args = NewArgs([]string{"remote", "add", "jingweno", "git@github.com:jingweno/gh.git"}) transformRemoteArgs(args) assert.Equal(t, 3, args.ParamsSize()) assert.Equal(t, "jingweno", args.GetParam(1)) assert.Equal(t, "add", args.FirstParam()) assert.Equal(t, "git@github.com:jingweno/gh.git", args.GetParam(2)) } ================================================ FILE: commands/runner.go ================================================ package commands import ( "fmt" "strings" "github.com/github/hub/v2/cmd" "github.com/github/hub/v2/git" "github.com/github/hub/v2/ui" "github.com/kballard/go-shellquote" ) type Runner struct { commands map[string]*Command } func NewRunner() *Runner { return &Runner{ commands: make(map[string]*Command), } } func (r *Runner) All() map[string]*Command { return r.commands } func (r *Runner) Use(command *Command, aliases ...string) { r.commands[command.Name()] = command if len(aliases) > 0 { r.commands[aliases[0]] = command } } func (r *Runner) Lookup(name string) *Command { return r.commands[name] } func (r *Runner) Execute(cliArgs []string) error { args := NewArgs(cliArgs[1:]) args.ProgramPath = cliArgs[0] forceFail := false if args.Command == "" && len(args.GlobalFlags) == 0 { args.Command = "help" forceFail = true } cmdName := args.Command if strings.Contains(cmdName, "=") { cmdName = strings.SplitN(cmdName, "=", 2)[0] } git.GlobalFlags = args.GlobalFlags // preserve git global flags if !isBuiltInHubCommand(cmdName) { expandAlias(args) cmdName = args.Command } // make ` --help` equivalent to `help ` if args.ParamsSize() == 1 && args.GetParam(0) == helpFlag { if c := r.Lookup(cmdName); c != nil && !c.GitExtension { args.ReplaceParam(0, cmdName) args.Command = "help" cmdName = args.Command } } cmd := r.Lookup(cmdName) if cmd != nil && cmd.Runnable() { err := callRunnableCommand(cmd, args) if err == nil && forceFail { err = fmt.Errorf("") } return err } gitArgs := []string{} if args.Command != "" { gitArgs = append(gitArgs, args.Command) } gitArgs = append(gitArgs, args.Params...) return git.Run(gitArgs...) } func callRunnableCommand(cmd *Command, args *Args) error { err := cmd.Call(args) if err != nil { return err } cmds := args.Commands() if args.Noop { printCommands(cmds) } else if err = executeCommands(cmds, len(args.Callbacks) == 0); err != nil { return err } for _, fn := range args.Callbacks { if err = fn(); err != nil { return err } } return nil } func printCommands(cmds []*cmd.Cmd) { for _, c := range cmds { ui.Println(c) } } func executeCommands(cmds []*cmd.Cmd, execFinal bool) error { for i, c := range cmds { var err error // Run with `Exec` for the last command in chain if execFinal && i == len(cmds)-1 { err = c.Run() } else { err = c.Spawn() } if err != nil { return err } } return nil } func expandAlias(args *Args) { cmd := args.Command if cmd == "" { return } expandedCmd, err := git.Alias(cmd) if err == nil && expandedCmd != "" && !git.IsBuiltInGitCommand(cmd) { words, e := splitAliasCmd(expandedCmd) if e == nil { args.Command = words[0] args.PrependParams(words[1:]...) } } } func isBuiltInHubCommand(command string) bool { for hubCommand := range CmdRunner.All() { if hubCommand == command { return true } } return false } func splitAliasCmd(cmd string) ([]string, error) { if cmd == "" { return nil, fmt.Errorf("alias can't be empty") } if strings.HasPrefix(cmd, "!") { return nil, fmt.Errorf("alias starting with ! can't be split") } words, err := shellquote.Split(cmd) if err != nil { return nil, err } return words, nil } ================================================ FILE: commands/runner_test.go ================================================ package commands import ( "testing" "github.com/github/hub/v2/internal/assert" ) func TestRunner_splitAliasCmd(t *testing.T) { _, err := splitAliasCmd("!source ~/.zshrc") assert.NotEqual(t, nil, err) words, err := splitAliasCmd("log --pretty=oneline --abbrev-commit --graph --decorate") assert.Equal(t, nil, err) assert.Equal(t, 5, len(words)) _, err = splitAliasCmd("") assert.NotEqual(t, nil, err) } ================================================ FILE: commands/submodule.go ================================================ package commands var cmdSubmodule = &Command{ Run: submodule, GitExtension: true, Usage: "submodule add [-p] [] [/] ", Long: `Add a git submodule for a GitHub repository. ## Examples: $ hub submodule add jingweno/gh vendor/gh > git submodule add git://github.com/jingweno/gh.git vendor/gh ## See also: hub-remote(1), hub(1), git-submodule(1) `, } func init() { CmdRunner.Use(cmdSubmodule) } func submodule(command *Command, args *Args) { if !args.IsParamsEmpty() { transformSubmoduleArgs(args) } } func transformSubmoduleArgs(args *Args) { var idx int if idx = args.IndexOfParam("add"); idx == -1 { return } args.RemoveParam(idx) transformCloneArgs(args) args.InsertParam(idx, "add") } ================================================ FILE: commands/sync.go ================================================ package commands import ( "fmt" "regexp" "strings" "github.com/github/hub/v2/git" "github.com/github/hub/v2/github" "github.com/github/hub/v2/ui" "github.com/github/hub/v2/utils" ) var cmdSync = &Command{ Run: sync, Usage: "sync [--color]", Long: `Fetch git objects from upstream and update local branches. - If the local branch is outdated, fast-forward it; - If the local branch contains unpushed work, warn about it; - If the branch seems merged and its upstream branch was deleted, delete it. If a local branch does not have any upstream configuration, but has a same-named branch on the remote, treat that as its upstream branch. ## Options: --color[=] Enable colored output even if stdout is not a terminal. can be one of "always" (default for ''--color''), "never", or "auto" (default). ## See also: hub(1), git-fetch(1) `, } func init() { CmdRunner.Use(cmdSync) } func sync(cmd *Command, args *Args) { localRepo, err := github.LocalRepo() utils.Check(err) remote, err := localRepo.MainRemote() utils.Check(err) defaultBranch := localRepo.DefaultBranch(remote).ShortName() fullDefaultBranch := fmt.Sprintf("refs/remotes/%s/%s", remote.Name, defaultBranch) currentBranch := "" if curBranch, err := localRepo.CurrentBranch(); err == nil { currentBranch = curBranch.ShortName() } err = git.Spawn("fetch", "--prune", "--quiet", "--progress", remote.Name) utils.Check(err) branchToRemote := map[string]string{} if lines, err := git.ConfigAll("branch.*.remote"); err == nil { configRe := regexp.MustCompile(`^branch\.(.+?)\.remote (.+)`) for _, line := range lines { if matches := configRe.FindStringSubmatch(line); len(matches) > 0 { branchToRemote[matches[1]] = matches[2] } } } branches, err := git.LocalBranches() utils.Check(err) var green, lightGreen, red, lightRed, resetColor string colorize := colorizeOutput(args.Flag.HasReceived("--color"), args.Flag.Value("--color")) if colorize { green = "\033[32m" lightGreen = "\033[32;1m" red = "\033[31m" lightRed = "\033[31;1m" resetColor = "\033[0m" } for _, branch := range branches { fullBranch := fmt.Sprintf("refs/heads/%s", branch) remoteBranch := fmt.Sprintf("refs/remotes/%s/%s", remote.Name, branch) gone := false if branchToRemote[branch] == remote.Name { if upstream, err := git.SymbolicFullName(fmt.Sprintf("%s@{upstream}", branch)); err == nil { remoteBranch = upstream } else { remoteBranch = "" gone = true } } else if !git.HasFile(strings.Split(remoteBranch, "/")...) { remoteBranch = "" } if remoteBranch != "" { diff, err := git.NewRange(fullBranch, remoteBranch) utils.Check(err) if diff.IsIdentical() { continue } else if diff.IsAncestor() { if branch == currentBranch { git.Quiet("merge", "--ff-only", "--quiet", remoteBranch) } else { git.Quiet("update-ref", fullBranch, remoteBranch) } ui.Printf("%sUpdated branch %s%s%s (was %s).\n", green, lightGreen, branch, resetColor, diff.A[0:7]) } else { ui.Errorf("warning: '%s' seems to contain unpushed commits\n", branch) } } else if gone { diff, err := git.NewRange(fullBranch, fullDefaultBranch) utils.Check(err) if diff.IsAncestor() { if branch == currentBranch { git.Quiet("checkout", "--quiet", defaultBranch) currentBranch = defaultBranch } git.Quiet("branch", "-D", branch) ui.Printf("%sDeleted branch %s%s%s (was %s).\n", red, lightRed, branch, resetColor, diff.A[0:7]) } else { ui.Errorf("warning: '%s' was deleted on %s, but appears not merged into '%s'\n", branch, remote.Name, defaultBranch) } } } args.NoForward() } ================================================ FILE: commands/utils.go ================================================ package commands import ( "bufio" "fmt" "io/ioutil" "os" "path/filepath" "strings" "github.com/atotto/clipboard" "github.com/github/hub/v2/git" "github.com/github/hub/v2/ui" "github.com/github/hub/v2/utils" ) type stringSliceValue []string func (s *stringSliceValue) Set(val string) error { *s = append(*s, val) return nil } func (s *stringSliceValue) String() string { return fmt.Sprintf("%s", *s) } type listFlag []string func (l *listFlag) String() string { return strings.Join([]string(*l), ",") } func (l *listFlag) Set(value string) error { for _, flag := range strings.Split(value, ",") { *l = append(*l, flag) } return nil } type messageBlocks []string func (m *messageBlocks) String() string { return strings.Join([]string(*m), "\n\n") } func (m *messageBlocks) Set(value string) error { *m = append(*m, value) return nil } func isCloneable(file string) bool { f, err := os.Open(file) if err != nil { return false } defer f.Close() fi, err := f.Stat() if err != nil { return false } if fi.IsDir() { gitf, err := os.Open(filepath.Join(file, ".git")) if err == nil { gitf.Close() return true } return git.IsGitDir(file) } reader := bufio.NewReader(f) line, err := reader.ReadString('\n') if err == nil { return strings.Contains(line, "git bundle") } return false } func isEmptyDir(path string) bool { fullPath := filepath.Join(path, "*") match, _ := filepath.Glob(fullPath) return match == nil } func msgFromFile(filename string) (string, error) { var content []byte var err error if filename == "-" { content, err = ioutil.ReadAll(os.Stdin) } else { content, err = ioutil.ReadFile(filename) } if err != nil { return "", err } return strings.Replace(string(content), "\r\n", "\n", -1), nil } func printBrowseOrCopy(args *Args, msg string, openBrowser bool, performCopy bool) { if performCopy { if err := clipboard.WriteAll(msg); err != nil { ui.Errorf("Error copying %s to clipboard:\n%s\n", msg, err.Error()) } } if openBrowser { launcher, err := utils.BrowserLauncher() utils.Check(err) args.Replace(launcher[0], "", launcher[1:]...) args.AppendParams(msg) } if !openBrowser && !performCopy { args.AfterFn(func() error { ui.Println(msg) return nil }) } } ================================================ FILE: commands/utils_test.go ================================================ package commands import ( "io/ioutil" "os" "testing" "github.com/github/hub/v2/internal/assert" ) func TestDirIsNotEmpty(t *testing.T) { dir := createTempDir(t) defer os.RemoveAll(dir) ioutil.TempFile(dir, "gh-utils-test-") assert.T(t, !isEmptyDir(dir)) } func TestDirIsEmpty(t *testing.T) { dir := createTempDir(t) defer os.RemoveAll(dir) assert.T(t, isEmptyDir(dir)) } func createTempDir(t *testing.T) string { dir, err := ioutil.TempDir("", "gh-utils-test-") if err != nil { t.Fatal(err) } return dir } ================================================ FILE: commands/version.go ================================================ package commands import ( "github.com/github/hub/v2/ui" "github.com/github/hub/v2/version" ) var cmdVersion = &Command{ Run: runVersion, Usage: "version", Long: "Shows git version and hub client version.", GitExtension: true, } func init() { CmdRunner.Use(cmdVersion, "--version") } func runVersion(cmd *Command, args *Args) { versionCmd := args.ToCmd() versionCmd.Spawn() ui.Printf("hub version %s\n", version.Version) args.NoForward() } ================================================ FILE: coverage/coverage.go ================================================ package coverage import ( "fmt" "io" "os" "reflect" "runtime" ) var out io.Writer var seen map[string]bool func init() { var err error out, err = os.OpenFile(os.Getenv("HUB_COVERAGE"), os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644) if err != nil { panic(err) } seen = make(map[string]bool) } func Record(data interface{}, i int) { _, filename, _, _ := runtime.Caller(1) if !seen[filename] { seen[filename] = true d := reflect.ValueOf(data) count := reflect.ValueOf(d.FieldByName("Count").Interface()) total := count.Len() for j := 0; j < total; j++ { write(data, j, 0, filename) } } write(data, i, 1, filename) } func write(data interface{}, i, count int, filename string) { d := reflect.ValueOf(data) pos := reflect.ValueOf(d.FieldByName("Pos").Interface()) numStmt := reflect.ValueOf(d.FieldByName("NumStmt").Interface()) fmt.Fprintf( out, "%s:%d.%d,%d.%d %d %d\n", filename, pos.Index(3*i).Uint(), pos.Index(3*i+2).Uint()&0xFFFF, pos.Index(3*i+1).Uint(), pos.Index(3*i+2).Uint()>>16&0xFFFF, numStmt.Index(i).Uint(), count, ) } ================================================ FILE: cucumber.yml ================================================ default: --format progress -t 'not @completion' completion: --format pretty -t @completion all: --format progress ================================================ FILE: etc/README.md ================================================ # Installation instructions ## Homebrew If you're using Homebrew, just run `brew install hub` and you should be all set with auto-completion. The extra steps to install hub completion scripts outlined below are *not needed*. For bash/zsh, a one-time setup might be needed to [enable completion for all Homebrew programs](https://docs.brew.sh/Shell-Completion). ## bash Open your `.bashrc` file if you're on Linux, or your `.bash_profile` if you're on macOS and add: ```sh if [ -f /path/to/hub.bash_completion ]; then . /path/to/hub.bash_completion fi ``` ## zsh Copy the file `etc/hub.zsh_completion` from the location where you downloaded `hub` to the folder `~/.zsh/completions/` and rename it to `_hub`: ```sh mkdir -p ~/.zsh/completions cp etc/hub.zsh_completion ~/.zsh/completions/_hub ``` Then add the following lines to your `.zshrc` file: ```sh fpath=(~/.zsh/completions $fpath) autoload -U compinit && compinit ``` ## fish Copy the file `etc/hub.fish_completion` from the location where you downloaded `hub` to the folder `~/.config/fish/completions/` and rename it to `hub.fish`: ```sh mkdir -p ~/.config/fish/completions cp etc/hub.fish_completion ~/.config/fish/completions/hub.fish ``` ================================================ FILE: etc/hub.bash_completion.sh ================================================ # hub tab-completion script for bash. # This script complements the completion script that ships with git. # If there is no git tab completion, but we have the _completion loader try to load it if ! declare -F _git > /dev/null && declare -F _completion_loader > /dev/null; then _completion_loader git fi # Check that git tab completion is available and we haven't already set up completion if declare -F _git > /dev/null && ! declare -F __git_list_all_commands_without_hub > /dev/null; then # Duplicate and rename the 'list_all_commands' function eval "$(declare -f __git_list_all_commands | \ sed 's/__git_list_all_commands/__git_list_all_commands_without_hub/')" # Wrap the 'list_all_commands' function with extra hub commands __git_list_all_commands() { cat <<-EOF alias pull-request pr issue release fork create delete browse compare ci-status sync EOF __git_list_all_commands_without_hub } # Ensure cached commands are cleared __git_all_commands="" ########################## # hub command completions ########################## # hub alias [-s] [SHELL] _git_alias() { local i c=2 s=-s sh shells="bash zsh sh ksh csh fish" while [ $c -lt $cword ]; do i="${words[c]}" case "$i" in -s) unset s ;; *) for sh in $shells; do if [ "$sh" = "$i" ]; then unset shells break fi done ;; esac ((c++)) done __gitcomp "$s $shells" } # hub browse [-u] [--|[USER/]REPOSITORY] [SUBPAGE] _git_browse() { local i c=2 u=-u repo subpage local subpages_="commits issues tree wiki pulls branches stargazers contributors network network/ graphs graphs/" local subpages_network="members" local subpages_graphs="commit-activity code-frequency punch-card" while [ $c -lt $cword ]; do i="${words[c]}" case "$i" in -u) unset u ;; *) if [ -z "$repo" ]; then repo=$i else subpage=$i fi ;; esac ((c++)) done if [ -z "$repo" ]; then __gitcomp "$u -- $(__hub_github_repos '\p')" elif [ -z "$subpage" ]; then case "$cur" in */*) local pfx="${cur%/*}" cur_="${cur#*/}" local subpages_var="subpages_$pfx" __gitcomp "${!subpages_var}" "$pfx/" "$cur_" ;; *) __gitcomp "$u ${subpages_}" ;; esac else __gitcomp "$u" fi } # hub compare [-u] [USER[/REPOSITORY]] [[START...]END] _git_compare() { local i c=$((cword - 1)) u=-u user remote owner repo arg_repo rev while [ $c -gt 1 ]; do i="${words[c]}" case "$i" in -u) unset u ;; *) if [ -z "$rev" ]; then # Even though the logic below is able to complete both user/repo # and revision in the right place, when there is only one argument # (other than -u) in the command, that argument will be taken as # revision. For example: # $ hub compare -u upstream # > https://github.com/USER/REPO/compare/upstream if __hub_github_repos '\p' | command grep -Eqx "^$i(/[^/]+)?"; then arg_repo=$i else rev=$i fi elif [ -z "$arg_repo" ]; then arg_repo=$i fi ;; esac ((c--)) done # Here we want to find out the git remote name of user/repo, in order to # generate an appropriate revision list if [ -z "$arg_repo" ]; then user=$(__hub_github_user) if [ -z "$user" ]; then for i in $(__hub_github_repos); do remote=${i%%:*} repo=${i#*:} if [ "$remote" = origin ]; then break fi done else for i in $(__hub_github_repos); do remote=${i%%:*} repo=${i#*:} owner=${repo%%/*} if [ "$user" = "$owner" ]; then break fi done fi else for i in $(__hub_github_repos); do remote=${i%%:*} repo=${i#*:} owner=${repo%%/*} case "$arg_repo" in "$repo"|"$owner") break ;; esac done fi local pfx cur_="$cur" case "$cur_" in *..*) pfx="${cur_%%..*}..." cur_="${cur_##*..}" __gitcomp_nl "$(__hub_revlist $remote)" "$pfx" "$cur_" ;; *) if [ -z "${arg_repo}${rev}" ]; then __gitcomp "$u $(__hub_github_repos '\o\n\p') $(__hub_revlist $remote)" elif [ -z "$rev" ]; then __gitcomp "$u $(__hub_revlist $remote)" else __gitcomp "$u" fi ;; esac } # hub create [NAME] [-p] [-d DESCRIPTION] [-h HOMEPAGE] _git_create() { local i c=2 name repo flags="-p -d -h" while [ $c -lt $cword ]; do i="${words[c]}" case "$i" in -d|-h) ((c++)) flags=${flags/$i/} ;; -p) flags=${flags/$i/} ;; *) name=$i ;; esac ((c++)) done if [ -z "$name" ]; then repo=$(basename "$(pwd)") fi case "$prev" in -d|-h) COMPREPLY=() ;; -p|*) __gitcomp "$repo $flags" ;; esac } # hub fork [--no-remote] [--remote-name REMOTE] [--org ORGANIZATION] _git_fork() { local i c=2 flags="--no-remote --remote-name --org" while [ $c -lt $cword ]; do i="${words[c]}" case "$i" in --org) ((c++)) flags=${flags/$i/} ;; --remote-name) ((c++)) flags=${flags/$i/} flags=${flags/--no-remote/} ;; --no-remote) flags=${flags/$i/} flags=${flags/--remote-name/} ;; esac ((c++)) done case "$prev" in --remote-name|--org) COMPREPLY=() ;; *) __gitcomp "$flags" ;; esac } # hub pull-request [-f] [-m |-F |-i |] [-b ] [-h ] [-a ] [-M ] [-l ] _git_pull_request() { local i c=2 flags="-f -m -F -i -b -h -a -M -l" while [ $c -lt $cword ]; do i="${words[c]}" case "$i" in -m|-F|-i|-b|-h|-a|-M|-l) ((c++)) flags=${flags/$i/} ;; -f) flags=${flags/$i/} ;; esac ((c++)) done case "$prev" in -i) COMPREPLY=() ;; -b|-h|-a|-M|-l) # (Doesn't seem to need this...) # Uncomment the following line when 'owner/repo:[TAB]' misbehaved #_get_comp_words_by_ref -n : cur __gitcomp_nl "$(__hub_heads)" # __ltrim_colon_completions "$cur" ;; -F) COMPREPLY=( "$cur"* ) ;; -f|*) __gitcomp "$flags" ;; esac } ################### # Helper functions ################### # __hub_github_user [HOST] # Return $GITHUB_USER or the default github user defined in hub config # HOST - Host to be looked-up in hub config. Default is "github.com" __hub_github_user() { if [ -n "$GITHUB_USER" ]; then echo $GITHUB_USER return fi local line h k v host=${1:-github.com} config=${HUB_CONFIG:-~/.config/hub} if [ -f "$config" ]; then while read line; do if [ "$line" = "---" ]; then continue fi k=${line%%:*} v=${line#*:} if [ -z "$v" ]; then if [ "$h" = "$host" ]; then break fi h=$k continue fi k=${k#* } v=${v#* } if [ "$h" = "$host" ] && [ "$k" = "user" ]; then echo "$v" break fi done < "$config" fi } # __hub_github_repos [FORMAT] # List all github hosted repository # FORMAT - Format string contains multiple of these: # \m remote # \p owner/repo # \o owner # escaped characters (\n, \t ...etc) work # If omitted, prints all github repos in the format of "remote:owner/repo" __hub_github_repos() { local f format=$1 if [ -z "$(__gitdir)" ]; then return fi if [ -z "$format" ]; then format='\1:\2' else format=${format//\m/\1} format=${format//\p/\2} format=${format//\o/\3} fi command git config --get-regexp 'remote\.[^.]*\.url' | command grep -E ' ((https?|git)://|git@)github\.com[:/][^:/]+/[^/]+$' | sed -E 's#^remote\.([^.]+)\.url +.+[:/](([^/]+)/[^.]+)(\.git)?$#'"$format"'#' } # __hub_heads # List all local "branch", and remote "owner/repo:branch" __hub_heads() { local i remote repo branch dir=$(__gitdir) if [ -d "$dir" ]; then command git --git-dir="$dir" for-each-ref --format='%(refname:short)' \ "refs/heads/" for i in $(__hub_github_repos); do remote=${i%%:*} repo=${i#*:} command git --git-dir="$dir" for-each-ref --format='%(refname:short)' \ "refs/remotes/${remote}/" | while read branch; do echo "${repo}:${branch#${remote}/}" done done fi } # __hub_revlist [REMOTE] # List all tags, and branches under REMOTE, without the "remote/" prefix # REMOTE - Remote name to search branches from. Default is "origin" __hub_revlist() { local i remote=${1:-origin} dir=$(__gitdir) if [ -d "$dir" ]; then command git --git-dir="$dir" for-each-ref --format='%(refname:short)' \ "refs/remotes/${remote}/" | while read i; do echo "${i#${remote}/}" done command git --git-dir="$dir" for-each-ref --format='%(refname:short)' \ "refs/tags/" fi } # Enable completion for hub even when not using the alias complete -o bashdefault -o default -o nospace -F _git hub 2>/dev/null \ || complete -o default -o nospace -F _git hub fi ================================================ FILE: etc/hub.fish_completion ================================================ complete -c hub --wraps git function __fish_hub_needs_command set cmd (commandline -opc) if [ (count $cmd) -eq 1 ] return 0 else return 1 end end function __fish_hub_using_command set cmd (commandline -opc) set subcmd_count (count $argv) if [ (count $cmd) -gt "$subcmd_count" ] for i in (seq 1 "$subcmd_count") if [ "$argv[$i]" != $cmd[(math "$i" + 1)] ] return 1 end end return 0 else return 1 end end function __fish_hub_prs command hub pr list -f %I\t%t%n 2>/dev/null end complete -f -c hub -n '__fish_hub_needs_command' -a alias -d "show shell instructions for wrapping git" complete -f -c hub -n '__fish_hub_needs_command' -a browse -d "browse the project on GitHub" complete -f -c hub -n '__fish_hub_needs_command' -a compare -d "lookup commit in GitHub Status API" complete -f -c hub -n '__fish_hub_needs_command' -a create -d "create new repo on GitHub for the current project" complete -f -c hub -n '__fish_hub_needs_command' -a delete -d "delete a GitHub repo" complete -f -c hub -n '__fish_hub_needs_command' -a fork -d "fork origin repo on GitHub" complete -f -c hub -n '__fish_hub_needs_command' -a pull-request -d "open a pull request on GitHub" complete -f -c hub -n '__fish_hub_needs_command' -a pr -d "list or checkout GitHub pull requests" complete -f -c hub -n '__fish_hub_needs_command' -a issue -d "list or create a GitHub issue" complete -f -c hub -n '__fish_hub_needs_command' -a release -d "list or create a GitHub release" complete -f -c hub -n '__fish_hub_needs_command' -a ci-status -d "display GitHub Status information for a commit" complete -f -c hub -n '__fish_hub_needs_command' -a sync -d "update local branches from upstream" # alias complete -f -c hub -n ' __fish_hub_using_command alias' -a 'bash zsh sh ksh csh fish' -d "output shell script suitable for eval" # pull-request complete -f -c hub -n ' __fish_hub_using_command pull-request' -s f -l force -d "Skip the check for unpushed commits" complete -f -c hub -n ' __fish_hub_using_command pull-request' -s m -l message -d "Set the pull request title and description separated by a blank line" complete -f -c hub -n ' __fish_hub_using_command pull-request' -l no-edit -d "Use the message from the first commit on the branch as pull request title and description without opening a text editor" complete -f -c hub -n ' __fish_hub_using_command pull-request' -s e -l edit -d "Open the pull request title and description in a text editor before submitting." complete -f -c hub -n ' __fish_hub_using_command pull-request' -s i -l issue -d "Convert (referenced by its number) to a pull request" complete -f -c hub -n ' __fish_hub_using_command pull-request' -s F --file -d "Read the pull request title and description from " complete -f -c hub -n ' __fish_hub_using_command pull-request' -s o -l browse -d "Open the new pull request in a web browser" complete -f -c hub -n ' __fish_hub_using_command pull-request' -s c -l copy -d "Put the URL of the new pull request to the clipboard instead of printing it" complete -f -c hub -n ' __fish_hub_using_command pull-request' -s p -l push -d "Push the current branch to before creating the pull request" complete -f -c hub -n ' __fish_hub_using_command pull-request' -s b -l base -d 'The base branch in "[OWNER:]BRANCH" format' complete -f -c hub -n ' __fish_hub_using_command pull-request' -s h -l head -d 'The head branch in "[OWNER:]BRANCH" format' complete -f -c hub -n ' __fish_hub_using_command pull-request' -s r -l reviewer -d 'A comma-separated list of GitHub handles to request a review from' complete -f -c hub -n ' __fish_hub_using_command pull-request' -s a -l assign -d 'A comma-separated list of GitHub handles to assign to this pull request' complete -f -c hub -n ' __fish_hub_using_command pull-request' -s M -l milestone -d "The milestone name to add to this pull request. Passing the milestone number is deprecated." complete -f -c hub -n ' __fish_hub_using_command pull-request' -s l -l labels -d "Add a comma-separated list of labels to this pull request" complete -f -c hub -n ' __fish_hub_using_command pull-request' -s d -l draft -d "Create the pull request as a draft" complete -f -c hub -n ' __fish_hub_using_command pull-request' -l no-maintainer-edits -d "When creating a pull request from a fork, this disallows project maintainers from being abler to push to the head branch of this fork" # pr set -l pr_commands list checkout show complete -f -c hub -n ' __fish_hub_using_command pr' -l color -xa 'always never auto' -d 'enable colored output even if stdout is not a terminal. WHEN can be one of "always" (default for --color), "never", or "auto" (default).' ## pr list complete -f -c hub -n " __fish_hub_using_command pr; and not __fish_seen_subcommand_from $pr_commands" -a list -d "list pull requests in the current repository" complete -f -c hub -n ' __fish_hub_using_command pr list' -s s -l state -xa 'open closed merged all' -d 'filter pull requests by STATE. default: open' complete -f -c hub -n ' __fish_hub_using_command pr list' -s h -l head -d 'show pull requests started from the specified head BRANCH in "[OWNER:]BRANCH" format' complete -f -c hub -n ' __fish_hub_using_command pr list' -s b -l base -d 'show pull requests based off the specified BRANCH' complete -f -c hub -n ' __fish_hub_using_command pr list' -s o -l sort -xa 'created updated popularity long-running' -d 'default: created' complete -f -c hub -n ' __fish_hub_using_command pr list' -s '^' -l sort-ascending -d 'sort by ascending dates instead of descending' complete -f -c hub -n ' __fish_hub_using_command pr list' -s f -l format -d 'pretty print the list of pull requests using format FORMAT (default: "%pC%>(8)%i%Creset %t% l%n")' complete -f -c hub -n ' __fish_hub_using_command pr list' -s L -l limit -d 'display only the first LIMIT issues' ## pr checkout complete -f -c hub -n " __fish_hub_using_command pr; and not __fish_seen_subcommand_from $pr_commands" -a checkout -d "check out the head of a pull request in a new branch" complete -f -r -c hub -n ' __fish_hub_using_command pr checkout' -a '(__fish_hub_prs)' ## pr show complete -f -c hub -n " __fish_hub_using_command pr; and not __fish_seen_subcommand_from $pr_commands" -a show -d "open a pull request page in a web browser" complete -f -c hub -n ' __fish_hub_using_command pr show' -a '(__fish_hub_prs)' complete -f -c hub -n ' __fish_hub_using_command pr show' -s u -d "print the pull request URL instead of opening it" complete -f -c hub -n ' __fish_hub_using_command pr show' -s c -d "put the pull request URL to clipboard instead of opening it" # fork complete -f -c hub -n ' __fish_hub_using_command fork' -l no-remote -d "Skip adding a git remote for the fork" # browse complete -f -c hub -n ' __fish_hub_using_command browse' -s u -d "Print the URL instead of opening it" complete -f -c hub -n ' __fish_hub_using_command browse' -s c -d "Put the URL in clipboard instead of opening it" complete -f -c hub -n ' __fish_hub_using_command browse' -a '-- commits' -d 'commits' complete -f -c hub -n ' __fish_hub_using_command browse' -a '-- contributors' -d 'contributors' complete -f -c hub -n ' __fish_hub_using_command browse' -a '-- issues' -d 'issues' complete -f -c hub -n ' __fish_hub_using_command browse' -a '-- pulls' -d 'pull requests' complete -f -c hub -n ' __fish_hub_using_command browse' -a '-- wiki' -d 'wiki' # compare complete -f -c hub -n ' __fish_hub_using_command compare' -s u -d 'Print the URL instead of opening it' # create complete -f -c hub -n ' __fish_hub_using_command create' -s o -d "Open the new repository in a web browser" complete -f -c hub -n ' __fish_hub_using_command create' -l browse -d "Open the new repository in a web browser" complete -f -c hub -n ' __fish_hub_using_command create' -s p -d "Create a private repository" complete -f -c hub -n ' __fish_hub_using_command create' -s c -d "Put the URL of the new repository to clipboard instead of printing it." complete -f -c hub -n ' __fish_hub_using_command create' -l copy -d "Put the URL of the new repository to clipboard instead of printing it." # delete complete -f -c hub -n ' __fish_hub_using_command delete' -s y -d "Skip the confirmation prompt" complete -f -c hub -n ' __fish_hub_using_command delete' -l yes -d "Skip the confirmation prompt" # ci-status complete -f -c hub -n ' __fish_hub_using_command ci-status' -s v -d "Print detailed report of all status checks and their URLs" ================================================ FILE: etc/hub.zsh_completion ================================================ #compdef hub # Zsh will source this file when attempting to autoload the "_hub" function, # typically on the first attempt to complete the hub command. We define two new # setup helper routines (one for the zsh-distributed version, one for the # git-distributed, bash-based version). Then we redefine the "_hub" function to # call "_git" after some other interception. # # This is pretty fragile, if you think about it. Any number of implementation # changes in the "_git" scripts could cause problems down the road. It would be # better if the stock git completions were just a bit more permissive about how # it allowed third-party commands to be added. (( $+functions[__hub_setup_zsh_fns] )) || __hub_setup_zsh_fns () { (( $+functions[_git-alias] )) || _git-alias () { _arguments \ '-s[output shell script suitable for eval]' \ '1::shell:(zsh bash csh)' } (( $+functions[_git-browse] )) || _git-browse () { _arguments \ '-u[output the URL]' \ '2::subpage:(wiki commits issues)' } (( $+functions[_git-compare] )) || _git-compare () { _arguments \ '-u[output the URL]' \ ':[start...]end range:' } (( $+functions[_git-create] )) || _git-create () { _arguments \ '::name (REPOSITORY or ORGANIZATION/REPOSITORY):' \ '-p[make repository private]' \ '-d[description]:description' \ '-h[home page]:repository home page URL:_urls' } (( $+functions[_git-fork] )) || _git-fork () { _arguments \ '--no-remote[do not add a remote for the new fork]' } (( $+functions[_git-pull-request] )) || _git-pull-request () { _arguments \ '-f[force (skip check for local commits)]' \ '-b[base]:base ("branch", "owner\:branch", "owner/repo\:branch"):' \ '-h[head]:head ("branch", "owner\:branch", "owner/repo\:branch"):' \ - set1 \ '-m[message]' \ '-F[file]' \ '--no-edit[use first commit message for pull request title/description]' \ '-a[user]' \ '-M[milestone]' \ '-l[labels]' \ - set2 \ '-i[issue]:issue number:' \ - set3 \ '::issue-url:_urls' } # stash the "real" command for later functions[_hub_orig_git_commands]=$functions[_git_commands] # Replace it with our own wrapper. declare -f _git_commands >& /dev/null && unfunction _git_commands _git_commands () { local ret=1 # call the original routine _call_function ret _hub_orig_git_commands # Effectively "append" our hub commands to the behavior of the original # _git_commands function. Using this wrapper function approach ensures # that we only offer the user the hub subcommands when the user is # actually trying to complete subcommands. hub_commands=( alias:'show shell instructions for wrapping git' pull-request:'open a pull request on GitHub' pr:'list or checkout a GitHub pull request' issue:'list or create a GitHub issue' release:'list or create a GitHub release' fork:'fork origin repo on GitHub' create:'create new repo on GitHub for the current project' delete:'delete a GitHub repo' browse:'browse the project on GitHub' compare:'open GitHub compare view' ci-status:'show status of GitHub checks for a commit' sync:'update local branches from upstream' ) _describe -t hub-commands 'hub command' hub_commands && ret=0 return ret } } (( $+functions[__hub_setup_bash_fns] )) || __hub_setup_bash_fns () { # TODO more bash-style fns needed here to complete subcommand args. They take # the form "_git_CMD" where "CMD" is something like "pull-request". # Duplicate and rename the 'list_all_commands' function eval "$(declare -f __git_list_all_commands | \ sed 's/__git_list_all_commands/__git_list_all_commands_without_hub/')" # Wrap the 'list_all_commands' function with extra hub commands __git_list_all_commands() { cat <<-EOF alias pull-request pr issue release fork create delete browse compare ci-status sync EOF __git_list_all_commands_without_hub } # Ensure cached commands are cleared __git_all_commands="" } # redefine _hub to a much smaller function in the steady state _hub () { # only attempt to intercept the normal "_git" helper functions once (( $+__hub_func_replacement_done )) || () { # At this stage in the shell's execution the "_git" function has not yet # been autoloaded, so the "_git_commands" or "__git_list_all_commands" # functions will not be defined. Call it now (with a bogus no-op service # to prevent premature completion) so that we can wrap them. if declare -f _git >& /dev/null ; then _hub_noop () { __hub_zsh_provided=1 } # zsh-provided will call this one __hub_noop_main () { __hub_git_provided=1 } # git-provided will call this one local service=hub_noop _git unfunction _hub_noop unfunction __hub_noop_main service=git fi if (( $__hub_zsh_provided )) ; then __hub_setup_zsh_fns elif (( $__hub_git_provided )) ; then __hub_setup_bash_fns fi __hub_func_replacement_done=1 } # Now perform the actual completion, allowing the "_git" function to call our # replacement "_git_commands" function as needed. Both versions expect # service=git or they will call nonexistent routines or end up in an infinite # loop. service=git declare -f _git >& /dev/null && _git } # make sure we actually attempt to complete on the first "tab" from the user _hub ================================================ FILE: features/README.md ================================================ # Cucumber features for hub How to run all features: ```sh make bin/cucumber bin/cucumber ``` Because this can take a couple of minutes, you may want to only run select files related to the functionality that you're developing: ```sh bin/cucumber feature/api.feature ``` The Cucumber test suite requires a Ruby development environment. If you want to avoid setting that up, you can run tests inside a Docker container: ```sh script/docker feature/api.feature ``` ## How it works Each scenario is actually making real invocations to `hub` on the command-line in the context of a real (dynamically created) git repository. Whenever a scenario requires talking to the GitHub API, a fake HTTP server is spun locally to replace the real GitHub API. This is done so that the test suite runs faster and is available offline as well. The fake API server is defined as a Sinatra app inline in each scenario: ``` Given the GitHub API server: """ post('/repos/github/hub/pulls') { status 200 } """ ``` ## How to write new tests The best way to learn to write new tests is to study the existing scenarios for commands that are similar to those that you want to add or change. Since Cucumber tests are written in a natural language, you mostly don't need to know Ruby to write new tests. ================================================ FILE: features/alias.feature ================================================ Feature: hub alias Scenario: bash instructions Given $SHELL is "/bin/bash" When I successfully run `hub alias` Then the output should contain exactly: """ # Wrap git automatically by adding the following to ~/.bash_profile: eval "$(hub alias -s)"\n """ Scenario: fish instructions Given $SHELL is "/usr/local/bin/fish" When I successfully run `hub alias` Then the output should contain exactly: """ # Wrap git automatically by adding the following to ~/.config/fish/functions/git.fish: function git --wraps hub --description 'Alias for hub, which wraps git to provide extra functionality with GitHub.' hub $argv end\n """ Scenario: rc instructions Given $SHELL is "/usr/local/bin/rc" When I successfully run `hub alias` Then the output should contain exactly: """ # Wrap git automatically by adding the following to $home/lib/profile: eval `{hub alias -s}\n """ Scenario: zsh instructions Given $SHELL is "/bin/zsh" When I successfully run `hub alias` Then the output should contain exactly: """ # Wrap git automatically by adding the following to ~/.zshrc: eval "$(hub alias -s)"\n """ Scenario: csh instructions Given $SHELL is "/bin/csh" When I successfully run `hub alias` Then the output should contain exactly: """ # Wrap git automatically by adding the following to ~/.cshrc: eval "`hub alias -s`"\n """ Scenario: tcsh instructions Given $SHELL is "/bin/tcsh" When I successfully run `hub alias` Then the output should contain exactly: """ # Wrap git automatically by adding the following to ~/.tcshrc: eval "`hub alias -s`"\n """ Scenario: bash code Given $SHELL is "/bin/bash" When I successfully run `hub alias -s` Then the output should contain exactly: """ alias git=hub\n """ Scenario: fish code Given $SHELL is "/usr/local/bin/fish" When I successfully run `hub alias -s` Then the output should contain exactly: """ alias git=hub\n """ Scenario: rc code Given $SHELL is "/usr/local/bin/rc" When I successfully run `hub alias -s` Then the output should contain exactly: """ fn git { builtin hub $* }\n """ Scenario: zsh code Given $SHELL is "/bin/zsh" When I successfully run `hub alias -s` Then the output should contain exactly: """ alias git=hub\n """ Scenario: csh code Given $SHELL is "/bin/csh" When I successfully run `hub alias -s` Then the output should contain exactly: """ alias git hub\n """ Scenario: tcsh code Given $SHELL is "/bin/tcsh" When I successfully run `hub alias -s` Then the output should contain exactly: """ alias git hub\n """ Scenario: unsupported shell Given $SHELL is "/bin/zwoosh" When I run `hub alias -s` Then the output should contain exactly: """ hub alias: unsupported shell supported shells: bash zsh sh ksh csh tcsh fish rc\n """ And the exit status should be 1 Scenario: unknown shell Given $SHELL is "" When I run `hub alias` Then the output should contain exactly: """ Error: couldn't detect shell type. Please specify your shell with `hub alias `\n """ And the exit status should be 1 Scenario: unknown shell output Given $SHELL is "" When I run `hub alias -s` Then the output should contain exactly: """ Error: couldn't detect shell type. Please specify your shell with `hub alias -s `\n """ And the exit status should be 1 ================================================ FILE: features/am.feature ================================================ Feature: hub am Background: Given I am in "git://github.com/mislav/dotfiles.git" git repo And I am "mislav" on github.com with OAuth token "OTOKEN" Scenario: Apply a local patch When I run `hub am some.patch` Then the git command should be unchanged Scenario: Apply commits from pull request Given the GitHub API server: """ get('/repos/mislav/dotfiles/pulls/387') { halt 400 unless request.env['HTTP_ACCEPT'] == 'application/vnd.github.v3.patch;charset=utf-8' generate_patch "Create a README" } """ When I successfully run `hub am -q -3 https://github.com/mislav/dotfiles/pull/387` Then the output should not contain anything Then the latest commit message should be "Create a README" Scenario: Apply commits when TMPDIR is empty Given $TMPDIR is "" Given the GitHub API server: """ get('/repos/mislav/dotfiles/pulls/387') { halt 400 unless request.env['HTTP_ACCEPT'] == 'application/vnd.github.v3.patch;charset=utf-8' generate_patch "Create a README" } """ When I successfully run `hub am -q https://github.com/mislav/dotfiles/pull/387` Then the latest commit message should be "Create a README" Scenario: Enterprise repo Given I am in "git://git.my.org/mislav/dotfiles.git" git repo And I am "mislav" on git.my.org with OAuth token "FITOKEN" And "git.my.org" is a whitelisted Enterprise host Given the GitHub API server: """ get('/api/v3/repos/mislav/dotfiles/pulls/387') { halt 400 unless request.env['HTTP_ACCEPT'] == 'application/vnd.github.v3.patch;charset=utf-8' generate_patch "Create a README" } """ When I successfully run `hub am -q -3 https://git.my.org/mislav/dotfiles/pull/387` Then the latest commit message should be "Create a README" Scenario: Apply patch from commit Given the GitHub API server: """ get('/repos/davidbalbert/dotfiles/commits/fdb9921') { halt 400 unless request.env['HTTP_ACCEPT'] == 'application/vnd.github.v3.patch;charset=utf-8' generate_patch "Create a README" } """ When I successfully run `hub am -q https://github.com/davidbalbert/dotfiles/commit/fdb9921` Then the latest commit message should be "Create a README" Scenario: Apply patch from commit in a pull request Given the GitHub API server: """ get('/repos/davidbalbert/dotfiles/commits/fdb9921') { halt 400 unless request.env['HTTP_ACCEPT'] == 'application/vnd.github.v3.patch;charset=utf-8' generate_patch "Create a README" } """ When I successfully run `hub am -q https://github.com/davidbalbert/dotfiles/pull/123/commits/fdb9921` Then the latest commit message should be "Create a README" Scenario: Apply patch from gist Given the GitHub API server: """ get('/gists/8da7fb575debd88c54cf', :host_name => 'api.github.com') { json :files => { 'file.diff' => { :raw_url => "https://gist.github.com/raw/8da7fb575debd88c54cf/SHA/file.diff" } } } get('/raw/8da7fb575debd88c54cf/SHA/file.diff', :host_name => 'gist.github.com') { halt 400 unless request.env['HTTP_ACCEPT'] == 'text/plain;charset=utf-8' generate_patch "Create a README" } """ When I successfully run `hub am -q https://gist.github.com/8da7fb575debd88c54cf` Then the latest commit message should be "Create a README" ================================================ FILE: features/api.feature ================================================ @cache_clear Feature: hub api Background: Given I am "octokitten" on github.com with OAuth token "OTOKEN" Scenario: GET resource Given the GitHub API server: """ get('/hello/world') { halt 401 unless request.env['HTTP_AUTHORIZATION'] == 'token OTOKEN' halt 401 unless request.env['HTTP_ACCEPT'] == 'application/vnd.github.v3+json;charset=utf-8' json :name => "Ed" } """ When I successfully run `hub api hello/world` Then the output should contain exactly: """ {"name":"Ed"} """ Scenario: GET Enterprise resource Given I am "octokitten" on git.my.org with OAuth token "FITOKEN" Given the GitHub API server: """ get('/api/v3/hello/world', :host_name => 'git.my.org') { halt 401 unless request.env['HTTP_AUTHORIZATION'] == 'token FITOKEN' json :name => "Ed" } """ And $GITHUB_HOST is "git.my.org" When I successfully run `hub api hello/world` Then the output should contain exactly: """ {"name":"Ed"} """ Scenario: Non-success response Given the GitHub API server: """ get('/hello/world') { status 400 json :name => "Ed" } """ When I run `hub api hello/world` Then the exit status should be 22 And the stdout should contain exactly: """ {"name":"Ed"} """ And the stderr should contain exactly "" Scenario: Non-success response flat output Given the GitHub API server: """ get('/hello/world') { status 400 json :name => "Ed" } """ When I run `hub api -t hello/world` Then the exit status should be 22 And the stdout should contain exactly: """ .name Ed\n """ And the stderr should contain exactly "" Scenario: Non-success response doesn't choke on non-JSON Given the GitHub API server: """ get('/hello/world') { status 400 content_type :text 'Something went wrong' } """ When I run `hub api -t hello/world` Then the exit status should be 22 And the stdout should contain exactly: """ Something went wrong """ And the stderr should contain exactly "" Scenario: GET query string Given the GitHub API server: """ get('/hello/world') { json Hash[*params.sort.flatten] } """ When I successfully run `hub api -XGET -Fname=Ed -Fnum=12 -Fbool=false -Fvoid=null hello/world` Then the output should contain exactly: """ {"bool":"false","name":"Ed","num":"12","void":""} """ Scenario: GET full URL Given the GitHub API server: """ get('/hello/world', :host_name => 'api.github.com') { halt 401 unless request.env['HTTP_AUTHORIZATION'] == 'token OTOKEN' json :name => "Faye" } """ When I successfully run `hub api https://api.github.com/hello/world` Then the output should contain exactly: """ {"name":"Faye"} """ Scenario: Paginate REST Given the GitHub API server: """ get('/comments') { assert :per_page => "6" page = (params[:page] || 1).to_i response.headers["Link"] = %(<#{request.url}&page=#{page+1}>; rel="next") if page < 3 json [{:page => page}] } """ When I successfully run `hub api --paginate comments?per_page=6` Then the output should contain exactly: """ [{"page":1}] [{"page":2}] [{"page":3}] """ Scenario: Paginate GraphQL Given the GitHub API server: """ post('/graphql') { variables = params[:variables] || {} page = (variables["endCursor"] || 1).to_i json :data => { :pageInfo => { :hasNextPage => page < 3, :endCursor => (page+1).to_s } } } """ When I successfully run `hub api --paginate graphql -f query=QUERY` Then the output should contain exactly: """ {"data":{"pageInfo":{"hasNextPage":true,"endCursor":"2"}}} {"data":{"pageInfo":{"hasNextPage":true,"endCursor":"3"}}} {"data":{"pageInfo":{"hasNextPage":false,"endCursor":"4"}}} """ Scenario: Avoid leaking token to a 3rd party Given the GitHub API server: """ get('/hello/world', :host_name => 'example.com') { halt 401 unless request.env['HTTP_AUTHORIZATION'].nil? json :name => "Jet" } """ When I successfully run `hub api http://example.com/hello/world` Then the output should contain exactly: """ {"name":"Jet"} """ Scenario: Request headers Given the GitHub API server: """ get('/hello/world') { json :accept => request.env['HTTP_ACCEPT'], :foo => request.env['HTTP_X_FOO'] } """ When I successfully run `hub api hello/world -H 'x-foo:bar' -H 'Accept: text/json'` Then the output should contain exactly: """ {"accept":"text/json","foo":"bar"} """ Scenario: Response headers Given the GitHub API server: """ get('/hello/world') { json({}) } """ When I successfully run `hub api hello/world -i` Then the output should contain "HTTP/1.1 200 OK" And the output should contain "Content-Length: 2" Scenario: POST fields Given the GitHub API server: """ post('/hello/world') { json Hash[*params.sort.flatten] } """ When I successfully run `hub api -f name=@hubot -Fnum=12 -Fbool=false -Fvoid=null hello/world` Then the output should contain exactly: """ {"bool":false,"name":"@hubot","num":12,"void":null} """ Scenario: POST raw fields Given the GitHub API server: """ post('/hello/world') { json Hash[*params.sort.flatten] } """ When I successfully run `hub api -fnum=12 -fbool=false hello/world` Then the output should contain exactly: """ {"bool":"false","num":"12"} """ Scenario: POST from stdin Given the GitHub API server: """ post('/graphql') { json :query => params[:query] } """ When I run `hub api -t -F query=@- graphql` interactively And I pass in: """ query { repository } """ Then the output should contain exactly: """ .query query {\\n repository\\n}\\n\n """ Scenario: POST body from file Given the GitHub API server: """ post('/create') { params[:obj].inspect } """ Given a file named "payload.json" with: """ {"obj": ["one", 2, null]} """ When I successfully run `hub api create --input payload.json` Then the output should contain exactly: """ ["one", 2, nil] """ Scenario: POST body from stdin Given the GitHub API server: """ post('/create') { params[:obj].inspect } """ When I run `hub api create --input -` interactively And I pass in: """ {"obj": {"name": "Ein", "datadog": true}} """ Then the output should contain exactly: """ {"name"=>"Ein", "datadog"=>true} """ Scenario: Pass extra GraphQL variables Given the GitHub API server: """ post('/graphql') { json(params[:variables]) } """ When I successfully run `hub api -F query='query {}' -Fname=Jet -Fsize=2 graphql` Then the output should contain exactly: """ {"name":"Jet","size":2} """ Scenario: Enterprise GraphQL Given I am "octokitten" on git.my.org with OAuth token "FITOKEN" Given the GitHub API server: """ post('/api/graphql', :host_name => 'git.my.org') { halt 401 unless request.env['HTTP_AUTHORIZATION'] == 'token FITOKEN' json :name => "Ed" } """ And $GITHUB_HOST is "git.my.org" When I successfully run `hub api graphql -f query=QUERY` Then the output should contain exactly: """ {"name":"Ed"} """ Scenario: Repo context Given I am in "git://github.com/octocat/Hello-World.git" git repo Given the GitHub API server: """ get('/repos/octocat/Hello-World/commits') { json :commits => 12 } """ When I successfully run `hub api repos/{owner}/{repo}/commits` Then the output should contain exactly: """ {"commits":12} """ Scenario: Multiple string interpolation Given I am in "git://github.com/octocat/Hello-World.git" git repo Given the GitHub API server: """ get('/repos/octocat/Hello-World/pulls') { json(params) } """ When I successfully run `hub api repos/{owner}/{repo}/pulls?head={owner}:{repo}` Then the output should contain exactly: """ {"head":"octocat:Hello-World"} """ Scenario: Repo context in graphql Given I am in "git://github.com/octocat/Hello-World.git" git repo Given the GitHub API server: """ post('/graphql') { json :query => params[:query] } """ When I run `hub api -t -F query=@- graphql` interactively And I pass in: """ repository(owner: "{owner}", name: "{repo}", nameWithOwner: "{owner}/{repo}") """ Then the output should contain exactly: """ .query repository(owner: "octocat", name: "Hello-World", nameWithOwner: "octocat/Hello-World")\\n\n """ Scenario: Cache response Given the GitHub API server: """ count = 0 get('/count') { count += 1 json :count => count } """ When I run `hub api -t 'count?a=1&b=2' --cache 5` Then it should pass with ".count 1" When I run `hub api -t 'count?b=2&a=1' --cache 5` Then it should pass with ".count 1" Scenario: Cache graphql response Given the GitHub API server: """ count = 0 post('/graphql') { halt 400 unless params[:query] =~ /^Q\d$/ count += 1 json :count => count } """ When I run `hub api -t graphql -F query=Q1 --cache 5` Then it should pass with ".count 1" When I run `hub api -t graphql -F query=Q1 --cache 5` Then it should pass with ".count 1" When I run `hub api -t graphql -F query=Q2 --cache 5` Then it should pass with ".count 2" Scenario: Cache client error response Given the GitHub API server: """ count = 0 get('/count') { count += 1 status 404 if count == 1 json :count => count } """ When I run `hub api -t count --cache 5` Then it should fail with ".count 1" When I run `hub api -t count --cache 5` Then it should fail with ".count 1" And the exit status should be 22 Scenario: Avoid caching server error response Given the GitHub API server: """ count = 0 get('/count') { count += 1 status 500 if count == 1 json :count => count } """ When I run `hub api -t count --cache 5` Then it should fail with ".count 1" When I run `hub api -t count --cache 5` Then it should pass with ".count 2" When I run `hub api -t count --cache 5` Then it should pass with ".count 2" Scenario: Avoid caching response if the OAuth token changes Given the GitHub API server: """ count = 0 get('/count') { count += 1 json :count => count } """ When I run `hub api -t count --cache 5` Then it should pass with ".count 1" Given I am "octocat" on github.com with OAuth token "TOKEN2" When I run `hub api -t count --cache 5` Then it should pass with ".count 2" Scenario: Honor rate limit with pagination Given the GitHub API server: """ get('/hello') { page = (params[:page] || 1).to_i if page < 2 response.headers['X-Ratelimit-Remaining'] = '0' response.headers['X-Ratelimit-Reset'] = Time.now.utc.to_i.to_s response.headers['Link'] = %(; rel="next") end json [{}] } """ When I successfully run `hub api --obey-ratelimit --paginate hello` Then the stderr should contain "API rate limit exceeded; pausing until " Scenario: Succumb to rate limit with pagination Given the GitHub API server: """ get('/hello') { page = (params[:page] || 1).to_i response.headers['X-Ratelimit-Remaining'] = '0' response.headers['X-Ratelimit-Reset'] = Time.now.utc.to_i.to_s if page == 2 status 403 json :message => "API rate limit exceeded" else response.headers['Link'] = %(; rel="next") json [{page:page}] end } """ When I run `hub api --paginate -t hello` Then the exit status should be 22 And the stderr should not contain "API rate limit exceeded" And the stdout should contain exactly: """ .[0].page 1 .message API rate limit exceeded\n """ Scenario: Honor rate limit for 403s Given the GitHub API server: """ count = 0 get('/hello') { count += 1 if count == 1 response.headers['X-Ratelimit-Remaining'] = '0' response.headers['X-Ratelimit-Reset'] = Time.now.utc.to_i.to_s halt 403 end json [{}] } """ When I successfully run `hub api --obey-ratelimit hello` Then the stderr should contain "API rate limit exceeded; pausing until " Scenario: 403 unrelated to rate limit Given the GitHub API server: """ get('/hello') { response.headers['X-Ratelimit-Remaining'] = '1' status 403 } """ When I run `hub api --obey-ratelimit hello` Then the exit status should be 22 Then the stderr should not contain "API rate limit exceeded" Scenario: Warn about insufficient OAuth scopes Given the GitHub API server: """ get('/hello') { response.headers['X-Accepted-Oauth-Scopes'] = 'repo, admin' response.headers['X-Oauth-Scopes'] = 'public_repo' status 403 json({}) } """ When I run `hub api hello` Then the exit status should be 22 And the output should contain exactly: """ {} Your access token may have insufficient scopes. Visit http://github.com/settings/tokens to edit the 'hub' token and enable one of the following scopes: admin, repo\n """ Scenario: Print the SSO challenge to stderr Given the GitHub API server: """ get('/orgs/acme') { response.headers['X-GitHub-SSO'] = 'required; url=http://example.com?auth=HASH' status 403 json({}) } """ When I run `hub api orgs/acme` Then the exit status should be 22 And the stderr should contain exactly: """ You must authorize your token to access this organization: http://example.com?auth=HASH\n """ ================================================ FILE: features/apply.feature ================================================ Feature: hub apply Background: Given I am in "git://github.com/mislav/dotfiles.git" git repo And I am "mislav" on github.com with OAuth token "OTOKEN" And I make a commit Scenario: Apply a local patch When I run `hub apply some.patch` Then the git command should be unchanged And the file "README.md" should not exist Scenario: Apply commits from pull request Given the GitHub API server: """ get('/repos/mislav/dotfiles/pulls/387') { halt 400 unless request.env['HTTP_ACCEPT'] == 'application/vnd.github.v3.patch;charset=utf-8' generate_patch "Create a README" } """ When I successfully run `hub apply -3 https://github.com/mislav/dotfiles/pull/387` Then a file named "README.md" should exist Scenario: Apply commits when TMPDIR is empty Given $TMPDIR is "" Given the GitHub API server: """ get('/repos/mislav/dotfiles/pulls/387') { halt 400 unless request.env['HTTP_ACCEPT'] == 'application/vnd.github.v3.patch;charset=utf-8' generate_patch "Create a README" } """ When I successfully run `hub apply https://github.com/mislav/dotfiles/pull/387` Then a file named "README.md" should exist Scenario: Enterprise repo Given I am in "git://git.my.org/mislav/dotfiles.git" git repo And I am "mislav" on git.my.org with OAuth token "FITOKEN" And "git.my.org" is a whitelisted Enterprise host Given the GitHub API server: """ get('/api/v3/repos/mislav/dotfiles/pulls/387') { halt 400 unless request.env['HTTP_ACCEPT'] == 'application/vnd.github.v3.patch;charset=utf-8' generate_patch "Create a README" } """ When I successfully run `hub apply https://git.my.org/mislav/dotfiles/pull/387` Then a file named "README.md" should exist Scenario: Apply patch from commit Given the GitHub API server: """ get('/repos/davidbalbert/dotfiles/commits/fdb9921') { halt 400 unless request.env['HTTP_ACCEPT'] == 'application/vnd.github.v3.patch;charset=utf-8' generate_patch "Create a README" } """ When I successfully run `hub apply https://github.com/davidbalbert/dotfiles/commit/fdb9921` Then a file named "README.md" should exist Scenario: Apply patch from gist Given the GitHub API server: """ get('/gists/8da7fb575debd88c54cf', :host_name => 'api.github.com') { json :files => { 'file.diff' => { :raw_url => "https://gist.github.com/raw/8da7fb575debd88c54cf/SHA/file.diff" } } } get('/raw/8da7fb575debd88c54cf/SHA/file.diff', :host_name => 'gist.github.com') { halt 400 unless request.env['HTTP_ACCEPT'] == 'text/plain;charset=utf-8' generate_patch "Create a README" } """ When I successfully run `hub apply https://gist.github.com/8da7fb575debd88c54cf` Then a file named "README.md" should exist ================================================ FILE: features/authentication.feature ================================================ Feature: OAuth authentication Background: Given I am in "dotfiles" git repo Scenario: Ask for username & password, create authorization Given the GitHub API server: """ post('/authorizations') { assert_basic_auth 'mislav', 'kitty' assert :scopes => ['repo', 'gist'], :note_url => 'https://hub.github.com/' status 201 json :token => 'OTOKEN' } get('/user') { halt 401 unless request.env['HTTP_AUTHORIZATION'] == 'token OTOKEN' json :login => 'MiSlAv' } post('/user/repos') { halt 401 unless request.env['HTTP_AUTHORIZATION'] == 'token OTOKEN' status 201 json :full_name => 'mislav/dotfiles' } """ When I run `hub create` interactively When I type "mislav" And I type "kitty" Then the output should contain "github.com username:" And the output should contain "github.com password for mislav (never stored):" And the exit status should be 0 And the file "~/.config/hub" should contain "user: MiSlAv" And the file "~/.config/hub" should contain "oauth_token: OTOKEN" And the file "~/.config/hub" should have mode "0600" Scenario: Prompt for username & password, receive personal access token Given the GitHub API server: """ get('/user') { halt 401 unless request.env['HTTP_AUTHORIZATION'] == 'token 0123456789012345678901234567890123456789' json :login => 'llIMLLib' } post('/user/repos') { halt 401 unless request.env['HTTP_AUTHORIZATION'] == 'token 0123456789012345678901234567890123456789' status 201 json :full_name => 'llimllib/dotfiles' } """ When I run `hub create` interactively When I type "llimllib" And I type "0123456789012345678901234567890123456789" And the exit status should be 0 And the file "../home/.config/hub" should contain "user: llIMLLib" And the file "../home/.config/hub" should contain: """ oauth_token: "0123456789012345678901234567890123456789" """ Scenario: Ask for username & password, receive password that looks like a token Given the GitHub API server: """ post('/authorizations') { assert_basic_auth 'llimllib', '0123456789012345678901234567890123456789' status 201 json :token => 'OTOKEN' } get('/user') { halt 401 unless request.env['HTTP_AUTHORIZATION'] == 'token OTOKEN' json :login => 'llIMLLib' } post('/user/repos') { halt 401 unless request.env['HTTP_AUTHORIZATION'] == 'token OTOKEN' status 201 json :full_name => 'llimllib/dotfiles' } """ When I run `hub create` interactively When I type "llimllib" And I type "0123456789012345678901234567890123456789" And the exit status should be 0 And the file "../home/.config/hub" should contain "user: llIMLLib" And the file "../home/.config/hub" should contain "oauth_token: OTOKEN" Scenario: Rename & retry creating authorization if there's a token name collision Given the GitHub API server: """ post('/authorizations') { assert_basic_auth 'mislav', 'kitty' if params[:note] =~ /\Ahub for .+ 3\Z/ status 201 json :token => 'OTOKEN' else status 422 json :message => 'Validation Failed', :errors => [{ :resource => 'OauthAccess', :code => 'already_exists', :field => 'description' }] end } get('/user') { json :login => 'MiSlAv' } post('/user/repos') { status 201 json :full_name => 'mislav/dotfiles' } """ When I run `hub create` interactively When I type "mislav" And I type "kitty" Then the output should contain "github.com username:" And the exit status should be 0 And the file "../home/.config/hub" should contain "oauth_token: OTOKEN" Scenario: Avoid getting caught up in infinite recursion while retrying token names Given the GitHub API server: """ tries = 0 post('/authorizations') { tries += 1 halt 400, json(:message => "too many tries") if tries >= 10 status 422 json :message => 'Validation Failed', :errors => [{ :resource => 'OauthAccess', :code => 'already_exists', :field => 'description' }] } """ When I run `hub create` interactively When I type "mislav" And I type "kitty" Then the output should contain: """ Error creating repository: Unprocessable Entity (HTTP 422) Duplicate value for "description" """ And the exit status should be 1 And the file "../home/.config/hub" should not exist Scenario: Credentials from GITHUB_USER & GITHUB_PASSWORD Given the GitHub API server: """ post('/authorizations') { assert_basic_auth 'mislav', 'kitty' status 201 json :token => 'OTOKEN' } get('/user') { json :login => 'mislav' } post('/user/repos') { status 201 json :full_name => 'mislav/dotfiles' } """ Given $GITHUB_USER is "mislav" And $GITHUB_PASSWORD is "kitty" When I successfully run `hub create` Then the output should not contain "github.com password for mislav" And the file "../home/.config/hub" should contain "oauth_token: OTOKEN" Scenario: XDG: legacy config found, credentials from GITHUB_USER & GITHUB_PASSWORD Given I am "mislav" on github.com with OAuth token "LTOKEN" And the GitHub API server: """ post('/authorizations') { assert_basic_auth 'mislav', 'kitty' status 201 json :token => 'OTOKEN' } get('/user') { json :login => 'mislav' } post('/user/repos') { halt 401 unless request.env['HTTP_AUTHORIZATION'] == 'token OTOKEN' status 201 json :full_name => 'mislav/dotfiles' } """ And $GITHUB_USER is "mislav" And $GITHUB_PASSWORD is "kitty" And $XDG_CONFIG_HOME is "$HOME/.xdg" When I successfully run `hub create` Then the file "../home/.xdg/hub" should contain "oauth_token: OTOKEN" And the stderr with expanded variables should contain exactly: """ Notice: config file found but not respected at: <$HOME>/.config/hub You might want to move it to `<$HOME>/.xdg/hub' to avoid re-authenticating.\n """ Scenario: XDG: config from secondary directories Given I am "mislav" on github.com with OAuth token "OTOKEN" And the GitHub API server: """ get('/user') { json :login => 'mislav' } post('/user/repos') { halt 401 unless request.env['HTTP_AUTHORIZATION'] == 'token OTOKEN' status 201 json :full_name => 'mislav/dotfiles' } """ And $GITHUB_USER is "mislav" And $GITHUB_PASSWORD is "kitty" And $XDG_CONFIG_HOME is "$HOME/.xdg" And $XDG_CONFIG_DIRS is "/etc/xdg-nonsense:$HOME/.xdg-dir" When I move the file named "../home/.config/hub" to "../home/.xdg-dir/hub" And I successfully run `hub create` Then the file "../home/.xdg/hub" should not exist And the stderr should contain exactly "" Scenario: Credentials from GITHUB_TOKEN Given the GitHub API server: """ get('/user') { halt 401 unless request.env["HTTP_AUTHORIZATION"] == "token OTOKEN" json :login => 'mislav' } post('/user/repos') { halt 401 unless request.env["HTTP_AUTHORIZATION"] == "token OTOKEN" status 201 json :full_name => 'mislav/dotfiles' } """ Given $GITHUB_TOKEN is "OTOKEN" When I successfully run `hub create` Then the output should not contain "github.com password" And the output should not contain "github.com username" And the file "../home/.config/hub" should not exist Scenario: Credentials from GITHUB_TOKEN when obtaining username fails Given I am in "git://github.com/monalisa/playground.git" git repo Given the GitHub API server: """ get('/user') { status 403 json :message => "Resource not accessible by integration", :documentation_url => "https://developer.github.com/v3/users/#get-the-authenticated-user" } """ Given $GITHUB_TOKEN is "OTOKEN" Given $GITHUB_USER is "" When I run `hub release show v1.2.0` Then the output should not contain "github.com password" And the output should not contain "github.com username" And the file "../home/.config/hub" should not exist And the exit status should be 1 And the stderr should contain exactly: """ Error getting current user: Forbidden (HTTP 403) Resource not accessible by integration You must specify GITHUB_USER via environment variable.\n """ Scenario: Credentials from GITHUB_TOKEN and GITHUB_USER Given I am in "git://github.com/monalisa/playground.git" git repo Given the GitHub API server: """ get('/user') { status 403 json :message => "Resource not accessible by integration", :documentation_url => "https://developer.github.com/v3/users/#get-the-authenticated-user" } get('/repos/monalisa/playground/releases') { halt 401 unless request.env["HTTP_AUTHORIZATION"] == "token OTOKEN" json [ { tag_name: 'v1.2.0', } ] } """ Given $GITHUB_TOKEN is "OTOKEN" Given $GITHUB_USER is "hubot" When I successfully run `hub release show v1.2.0` Then the output should not contain "github.com password" And the output should not contain "github.com username" And the file "../home/.config/hub" should not exist Scenario: Credentials from GITHUB_TOKEN and GITHUB_REPOSITORY Given I am in "git://github.com/monalisa/playground.git" git repo Given the GitHub API server: """ get('/user') { status 403 json :message => "Resource not accessible by integration", :documentation_url => "https://developer.github.com/v3/users/#get-the-authenticated-user" } get('/repos/monalisa/playground/releases') { halt 401 unless request.env["HTTP_AUTHORIZATION"] == "token OTOKEN" json [ { tag_name: 'v1.2.0', } ] } """ Given $GITHUB_TOKEN is "OTOKEN" Given $GITHUB_REPOSITORY is "mona-lisa/play-ground" Given $GITHUB_USER is "" When I successfully run `hub release show v1.2.0` Then the output should not contain "github.com password" And the output should not contain "github.com username" And the file "../home/.config/hub" should not exist Scenario: Credentials from GITHUB_TOKEN override those from config file Given I am "mislav" on github.com with OAuth token "OTOKEN" Given the GitHub API server: """ get('/user') { halt 401 unless request.env["HTTP_AUTHORIZATION"] == "token PTOKEN" json :login => 'parkr' } get('/repos/parkr/dotfiles') { halt 401 unless request.env["HTTP_AUTHORIZATION"] == "token PTOKEN" json :private => false, :name => 'dotfiles', :owner => { :login => 'parkr' }, :permissions => { :push => true } } """ Given $GITHUB_TOKEN is "PTOKEN" When I successfully run `hub clone dotfiles` Then it should clone "https://github.com/parkr/dotfiles.git" And the file "../home/.config/hub" should contain "user: mislav" And the file "../home/.config/hub" should contain "oauth_token: OTOKEN" Scenario: Wrong password Given the GitHub API server: """ post('/authorizations') { assert_basic_auth 'mislav', 'kitty' } """ When I run `hub create` interactively When I type "mislav" And I type "WRONG" Then the stderr should contain exactly: """ Error creating repository: Unauthorized (HTTP 401) Bad credentials """ And the exit status should be 1 And the file "../home/.config/hub" should not exist Scenario: Two-factor authentication, create authorization Given the GitHub API server: """ post('/authorizations') { assert_basic_auth 'mislav', 'kitty' if request.env['HTTP_X_GITHUB_OTP'] == '112233' status 201 json :token => 'OTOKEN' else response.headers['X-GitHub-OTP'] = 'required; app' status 401 json :message => "Must specify two-factor authentication OTP code." end } get('/user') { json :login => 'mislav' } post('/user/repos') { status 201 json :full_name => 'mislav/dotfiles' } """ When I run `hub create` interactively When I type "mislav" And I type "kitty" And I type "112233" Then the output should contain "github.com password for mislav (never stored):" Then the output should contain "two-factor authentication code:" And the output should not contain "warning: invalid two-factor code" And the exit status should be 0 And the file "../home/.config/hub" should contain "oauth_token: OTOKEN" Scenario: Retry entering two-factor authentication code Given the GitHub API server: """ previous_otp_code = nil post('/authorizations') { assert_basic_auth 'mislav', 'kitty' if request.env['HTTP_X_GITHUB_OTP'] == '112233' halt 400 unless '666' == previous_otp_code status 201 json :token => 'OTOKEN' else previous_otp_code = request.env['HTTP_X_GITHUB_OTP'] response.headers['X-GitHub-OTP'] = 'required; app' status 401 json :message => "Must specify two-factor authentication OTP code." end } get('/user') { json :login => 'mislav' } post('/user/repos') { status 201 json :full_name => 'mislav/dotfiles' } """ When I run `hub create` interactively When I type "mislav" And I type "kitty" And I type "666" And I type "112233" Then the output should contain "warning: invalid two-factor code" And the exit status should be 0 And the file "../home/.config/hub" should contain "oauth_token: OTOKEN" Scenario: Special characters in username & password Given the GitHub API server: """ post('/authorizations') { assert_basic_auth 'mislav@example.com', 'my pass@phrase ok?' status 201 json :token => 'OTOKEN' } get('/user') { json :login => 'mislav' } get('/repos/mislav/dotfiles') { json :full_name => 'mislav/dotfiles' } """ When I run `hub create` interactively When I type "mislav@example.com" And I type "my pass@phrase ok?" Then the output should contain "github.com password for mislav@example.com (never stored):" And the exit status should be 0 And the file "../home/.config/hub" should contain "user: mislav" And the file "../home/.config/hub" should contain "oauth_token: OTOKEN" Scenario: Enterprise fork authentication with username & password, re-using existing authorization Given the GitHub API server: """ require 'rack/auth/basic' post('/api/v3/authorizations', :host_name => 'git.my.org') { auth = Rack::Auth::Basic::Request.new(env) halt 401 unless auth.credentials == %w[mislav kitty] status 201 json :token => 'OTOKEN', :note_url => 'https://hub.github.com/' } get('/api/v3/user', :host_name => 'git.my.org') { json :login => 'mislav' } post('/api/v3/repos/evilchelu/dotfiles/forks', :host_name => 'git.my.org') { status 202 json :name => 'dotfiles', :owner => { :login => 'mislav' } } """ And "git.my.org" is a whitelisted Enterprise host And the "origin" remote has url "git@git.my.org:evilchelu/dotfiles.git" When I run `hub fork` interactively And I type "mislav" And I type "kitty" Then the output should contain "git.my.org password for mislav (never stored):" And the exit status should be 0 And the file "../home/.config/hub" should contain "git.my.org" And the file "../home/.config/hub" should contain "user: mislav" And the file "../home/.config/hub" should contain "oauth_token: OTOKEN" And the url for "mislav" should be "https://git.my.org/mislav/dotfiles.git" Scenario: Broken config is missing user. Given a file named "../home/.config/hub" with: """ github.com: - oauth_token: OTOKEN protocol: https """ And the "origin" remote has url "git://github.com/mislav/coral.git" When I run `hub browse -u` interactively And I type "pcorpet" Then the output should contain "github.com username:" And the file "../home/.config/hub" should contain "- user: pcorpet" And the file "../home/.config/hub" should contain " oauth_token: OTOKEN" Scenario: Broken config is missing user and interactive input is empty. Given a file named "../home/.config/hub" with: """ github.com: - oauth_token: OTOKEN protocol: https """ And the "origin" remote has url "git://github.com/mislav/coral.git" When I run `hub browse -u` interactively And I type "" Then the output should contain "github.com username:" And the output should contain "missing user" And the file "../home/.config/hub" should not contain "user" Scenario: Config file is not writeable, should exit before asking for credentials Given $HUB_CONFIG is "/InvalidConfigFile" When I run `hub create` interactively Then the output should contain: """ open /InvalidConfigFile: """ And the exit status should be 1 And the file "../home/.config/hub" should not exist Scenario: Config file is not writeable on default location, should exit before asking for credentials Given a directory named "../home/.config" with mode "600" When I run `hub create` interactively Then the output with expanded variables should contain: """ <$HOME>/.config/hub: permission denied\n """ And the exit status should be 1 And the file "../home/.config/hub" should not exist Scenario: GitHub SSO challenge Given I am "monalisa" on github.com with OAuth token "OTOKEN" And I am in "git://github.com/acme/playground.git" git repo Given the GitHub API server: """ get('/repos/acme/playground/releases') { response.headers['X-GitHub-SSO'] = 'required; url=http://example.com?auth=HASH' status 403 } """ When I run `hub release show v1.2.0` Then the stderr should contain exactly: """ Error fetching releases: Forbidden (HTTP 403) You must authorize your token to access this organization: http://example.com?auth=HASH\n """ ================================================ FILE: features/bash_completion.feature ================================================ @completion Feature: bash tab-completion Background: Given my shell is bash And I'm using git-distributed base git completions Scenario: "pu" matches multiple commands including "pull-request" When I type "git pu" and press Then the command should not expand When I press again Then the completion menu should offer "pull pull-request push" Scenario: "ci-" expands to "ci-status" When I type "git ci-" and press Then the command should expand to "git ci-status" Scenario: Offers pull-request flags When I type "git pull-request -" and press When I press again Then the completion menu should offer "-F -b -f -h -i -m -a -M -l" unsorted Scenario: Doesn't offer already used pull-request flags When I type "git pull-request -F myfile -h mybranch -" and press When I press again Then the completion menu should offer "-b -f -i -m -a -M -l" unsorted Scenario: Browse to issues When I type "git browse -- i" and press Then the command should expand to "git browse -- issues" Scenario: Browse to punch-card graph When I type "git browse -- graphs/p" and press Then the command should expand to "git browse -- graphs/punch-card" Scenario: Completion of fork argument When I type "git fork -" and press When I press again Then the completion menu should offer "--no-remote --remote-name --org" unsorted Scenario: Completion of user/repo in "browse" Scenario: Completion of branch names in "compare" Scenario: Completion of "owner/repo:branch" in "pull-request -h/b" ================================================ FILE: features/browse.feature ================================================ Feature: hub browse Background: Given I am "mislav" on github.com with OAuth token "OTOKEN" Scenario: No repo When I run `hub browse` Then the exit status should be 1 Then the output should contain exactly "Usage: hub browse [-uc] [[/]|--] []\n" Scenario: Project with owner When I successfully run `hub browse mislav/dotfiles` Then the output should not contain anything And "open https://github.com/mislav/dotfiles" should be run Scenario: Project without owner Given I am "mislav" on github.com When I successfully run `hub browse dotfiles` Then "open https://github.com/mislav/dotfiles" should be run Scenario: Explicit project overrides current Given I am in "git://github.com/josh/rails-behaviors.git" git repo And I am "mislav" on github.com When I successfully run `hub browse dotfiles` Then "open https://github.com/mislav/dotfiles" should be run Scenario: Project issues When I successfully run `hub browse mislav/dotfiles issues` Then "open https://github.com/mislav/dotfiles/issues" should be run Scenario: Project wiki When I successfully run `hub browse mislav/dotfiles wiki` Then "open https://github.com/mislav/dotfiles/wiki" should be run Scenario: Project commits on master When I successfully run `hub browse mislav/dotfiles commits` Then "open https://github.com/mislav/dotfiles/commits/master" should be run Scenario: Specific commit in project When I successfully run `hub browse mislav/dotfiles commit/4173c3b` Then "open https://github.com/mislav/dotfiles/commit/4173c3b" should be run Scenario: Output the URL instead of browse When I successfully run `hub browse -u mislav/dotfiles` Then the output should contain exactly "https://github.com/mislav/dotfiles\n" But "open https://github.com/mislav/dotfiles" should not be run Scenario: Current project Given I am in "git://github.com/mislav/dotfiles.git" git repo When I successfully run `hub browse` Then the output should not contain anything And "open https://github.com/mislav/dotfiles" should be run Scenario: Commit in current project Given I am in "git://github.com/mislav/dotfiles.git" git repo When I successfully run `hub browse -- commit/abcd1234` Then "open https://github.com/mislav/dotfiles/commit/abcd1234" should be run Scenario: Current branch Given I am in "git://github.com/mislav/dotfiles.git" git repo And git "push.default" is set to "upstream" And I am on the "feature" branch with upstream "origin/experimental" When I successfully run `hub browse` Then "open https://github.com/mislav/dotfiles/tree/experimental" should be run Scenario: Current branch pushed to fork Given I am in "git://github.com/blueyed/dotfiles.git" git repo And the "mislav" remote has url "git@github.com:mislav/dotfiles.git" And I am on the "feature" branch with upstream "mislav/experimental" And git "push.default" is set to "upstream" When I successfully run `hub browse` Then "open https://github.com/mislav/dotfiles/tree/experimental" should be run Scenario: Current branch pushed to fork with simple tracking Given I am in "git://github.com/blueyed/dotfiles.git" git repo And the "mislav" remote has url "git@github.com:mislav/dotfiles.git" And I am on the "feature" branch with upstream "mislav/feature" And git "push.default" is set to "simple" When I successfully run `hub browse` Then "open https://github.com/mislav/dotfiles/tree/feature" should be run Scenario: Default branch Given I am in "git://github.com/mislav/dotfiles.git" git repo And the default branch for "origin" is "develop" And I am on the "develop" branch with upstream "origin/develop" When I successfully run `hub browse` Then "open https://github.com/mislav/dotfiles" should be run Scenario: Current branch, no tracking Given I am in "git://github.com/mislav/dotfiles.git" git repo And I am on the "feature" branch When I successfully run `hub browse` Then "open https://github.com/mislav/dotfiles" should be run Scenario: Default branch in upstream repo as opposed to fork Given I am in "git://github.com/jashkenas/coffee-script.git" git repo And the "mislav" remote has url "git@github.com:mislav/coffee-script.git" And the default branch for "origin" is "master" And the "master" branch is pushed to "mislav/master" When I successfully run `hub browse` Then "open https://github.com/jashkenas/coffee-script" should be run Scenario: Current branch with special chars Given I am in "git://github.com/mislav/dotfiles.git" git repo And I am on the "fix-bug-#123" branch with upstream "origin/fix-bug-#123" When I successfully run `hub browse` Then "open https://github.com/mislav/dotfiles/tree/fix-bug-%23123" should be run Scenario: Commits on current branch Given I am in "git://github.com/mislav/dotfiles.git" git repo And git "push.default" is set to "upstream" And I am on the "feature" branch with upstream "origin/experimental" When I successfully run `hub browse -- commits` Then "open https://github.com/mislav/dotfiles/commits/experimental" should be run Scenario: Issues subpage ignores tracking configuration Given I am in "git://github.com/jashkenas/coffee-script.git" git repo And the "mislav" remote has url "git@github.com:mislav/coffee-script.git" And git "push.default" is set to "upstream" And I am on the "feature" branch with upstream "mislav/experimental" When I successfully run `hub browse -- issues` Then "open https://github.com/jashkenas/coffee-script/issues" should be run Scenario: Issues subpage ignores current branch Given I am in "git://github.com/jashkenas/coffee-script.git" git repo And the "mislav" remote has url "git@github.com:mislav/coffee-script.git" And I am on the "feature" branch pushed to "mislav/feature" When I successfully run `hub browse -- issues` Then the output should not contain anything Then "open https://github.com/jashkenas/coffee-script/issues" should be run Scenario: Forward Slash Delimited branch Given I am in "git://github.com/mislav/dotfiles.git" git repo And git "push.default" is set to "upstream" And I am on the "foo/bar" branch with upstream "origin/baz/qux/moo" When I successfully run `hub browse` Then "open https://github.com/mislav/dotfiles/tree/baz/qux/moo" should be run Scenario: No branch Given I am in "git://github.com/mislav/dotfiles.git" git repo And I am in detached HEAD When I successfully run `hub browse` Then "open https://github.com/mislav/dotfiles" should be run Scenario: No branch to pulls Given I am in "git://github.com/mislav/dotfiles.git" git repo And I am in detached HEAD When I successfully run `hub browse -- pulls` Then "open https://github.com/mislav/dotfiles/pulls" should be run Scenario: Dot Delimited branch Given I am in "git://github.com/mislav/dotfiles.git" git repo And git "push.default" is set to "upstream" And I am on the "fix-glob-for.js" branch with upstream "origin/fix-glob-for.js" When I successfully run `hub browse` Then "open https://github.com/mislav/dotfiles/tree/fix-glob-for.js" should be run Scenario: Wiki repo Given I am in "git://github.com/defunkt/hub.wiki.git" git repo When I successfully run `hub browse` Then "open https://github.com/defunkt/hub/wiki" should be run Scenario: Wiki commits Given I am in "git://github.com/defunkt/hub.wiki.git" git repo When I successfully run `hub browse -- commits` Then "open https://github.com/defunkt/hub/wiki/_history" should be run Scenario: Wiki pages Given I am in "git://github.com/defunkt/hub.wiki.git" git repo When I successfully run `hub browse -- pages` Then "open https://github.com/defunkt/hub/wiki/_pages" should be run Scenario: Repo with remote with local path Given I am in "git://github.com/mislav/dotfiles.git" git repo And the "upstream" remote has url "../path/to/another/repo.git" When I successfully run `hub browse` Then "open https://github.com/mislav/dotfiles" should be run Scenario: Enterprise repo Given I am in "git://git.my.org/mislav/dotfiles.git" git repo And I am "mislav" on git.my.org with OAuth token "FITOKEN" And "git.my.org" is a whitelisted Enterprise host When I successfully run `hub browse` Then "open https://git.my.org/mislav/dotfiles" should be run Scenario: Multiple Enterprise repos Given I am in "git://git.my.org/mislav/dotfiles.git" git repo And I am "mislav" on git.my.org with OAuth token "FITOKEN" And "git.my.org" is a whitelisted Enterprise host And "git.another.org" is a whitelisted Enterprise host When I successfully run `hub browse` Then "open https://git.my.org/mislav/dotfiles" should be run Scenario: Enterprise repo over HTTP Given I am in "git://git.my.org/mislav/dotfiles.git" git repo And I am "mislav" on http://git.my.org with OAuth token "FITOKEN" And "git.my.org" is a whitelisted Enterprise host When I successfully run `hub browse` Then "open http://git.my.org/mislav/dotfiles" should be run Scenario: SSH alias Given the SSH config: """ Host gh User git HostName github.com """ Given I am in "gh:singingwolfboy/sekrit.git" git repo When I successfully run `hub browse` Then "open https://github.com/singingwolfboy/sekrit" should be run Scenario: SSH GitHub alias Given the SSH config: """ Host github.com HostName ssh.github.com """ Given I am in "git@github.com:suan/git-sanity.git" git repo When I successfully run `hub browse` Then "open https://github.com/suan/git-sanity" should be run ================================================ FILE: features/checkout.feature ================================================ Feature: hub checkout Background: Given I am in "git://github.com/mojombo/jekyll.git" git repo And I am "mislav" on github.com with OAuth token "OTOKEN" Scenario: Unchanged command When I run `hub checkout master` Then "git checkout master" should be run Scenario: Checkout a pull request Given the GitHub API server: """ get('/repos/mojombo/jekyll/pulls/77') { halt 415 unless request.accept?('application/vnd.github.v3+json') json :number => 77, :head => { :ref => "fixes", :repo => { :owner => { :login => "mislav" }, :name => "jekyll", :private => false } }, :base => { :repo => { :name => 'jekyll', :html_url => 'https://github.com/mojombo/jekyll', :owner => { :login => "mojombo" }, } }, :maintainer_can_modify => false } """ When I successfully run `hub checkout -f https://github.com/mojombo/jekyll/pull/77 -q` Then "git fetch origin refs/pull/77/head:fixes" should be run And "git checkout -f fixes -q" should be run And "fixes" should merge "refs/pull/77/head" from remote "origin" Scenario: Avoid overriding existing merge configuration Given the GitHub API server: """ get('/repos/mojombo/jekyll/pulls/77') { json :number => 77, :head => { :ref => "fixes", :repo => { :owner => { :login => "mislav" }, :name => "jekyll", :private => false } }, :base => { :repo => { :name => 'jekyll', :html_url => 'https://github.com/mojombo/jekyll', :owner => { :login => "mojombo" }, } }, :maintainer_can_modify => false } """ Given I successfully run `git config branch.fixes.remote ORIG_REMOTE` Given I successfully run `git config branch.fixes.merge custom/ref/spec` When I successfully run `hub checkout https://github.com/mojombo/jekyll/pull/77` Then "git fetch origin refs/pull/77/head:fixes" should be run And "git checkout fixes" should be run And "fixes" should merge "custom/ref/spec" from remote "ORIG_REMOTE" Scenario: Head ref matches default branch Given the GitHub API server: """ get('/repos/mojombo/jekyll/pulls/77') { json :number => 77, :head => { :ref => "master", :repo => { :owner => { :login => "mislav" }, :name => "jekyll", :default_branch => "master", :private => false } }, :base => { :repo => { :name => 'jekyll', :html_url => 'https://github.com/mojombo/jekyll', :owner => { :login => "mojombo" }, } }, :maintainer_can_modify => false } """ When I successfully run `hub checkout https://github.com/mojombo/jekyll/pull/77` Then "git fetch origin refs/pull/77/head:mislav-master" should be run And "git checkout mislav-master" should be run And "mislav-master" should merge "refs/pull/77/head" from remote "origin" Scenario: No matching remotes for pull request base Given the GitHub API server: """ get('/repos/mislav/jekyll/pulls/77') { json :number => 77, :base => { :repo => { :name => 'jekyll', :html_url => 'https://github.com/mislav/jekyll', :owner => { :login => "mislav" }, } } } """ When I run `hub checkout -f https://github.com/mislav/jekyll/pull/77 -q` Then the exit status should be 1 And the stderr should contain exactly: """ could not find a git remote for 'mislav/jekyll'\n """ Scenario: Custom name for new branch Given the GitHub API server: """ get('/repos/mojombo/jekyll/pulls/77') { json :number => 77, :head => { :ref => "fixes", :repo => { :name => "jekyll", :owner => { :login => "mislav" }, } }, :base => { :repo => { :name => 'jekyll', :html_url => 'https://github.com/mojombo/jekyll', :owner => { :login => "mojombo" }, } }, :maintainer_can_modify => false } """ When I successfully run `hub checkout https://github.com/mojombo/jekyll/pull/77 fixes-from-mislav` Then "git fetch origin refs/pull/77/head:fixes-from-mislav" should be run And "git checkout fixes-from-mislav" should be run And "fixes-from-mislav" should merge "refs/pull/77/head" from remote "origin" Scenario: Same-repo Given the GitHub API server: """ get('/repos/mojombo/jekyll/pulls/77') { json :number => 77, :head => { :ref => "fixes", :repo => { :name => "jekyll", :owner => { :login => "mojombo" }, } }, :base => { :repo => { :name => "jekyll", :html_url => "https://github.com/mojombo/jekyll", :owner => { :login => "mojombo" }, } } } """ When I successfully run `hub checkout -f https://github.com/mojombo/jekyll/pull/77 -q` Then "git fetch origin +refs/heads/fixes:refs/remotes/origin/fixes" should be run And "git checkout -f -b fixes --no-track origin/fixes -q" should be run And "fixes" should merge "refs/heads/fixes" from remote "origin" Scenario: Same-repo with custom branch name Given the GitHub API server: """ get('/repos/mojombo/jekyll/pulls/77') { json :number => 77, :head => { :ref => "fixes", :repo => { :name => "jekyll", :owner => { :login => "mojombo" }, } }, :base => { :repo => { :name => "jekyll", :html_url => "https://github.com/mojombo/jekyll", :owner => { :login => "mojombo" }, } } } """ When I successfully run `hub checkout https://github.com/mojombo/jekyll/pull/77 mycustombranch` Then "git fetch origin +refs/heads/fixes:refs/remotes/origin/fixes" should be run And "git checkout -b mycustombranch --no-track origin/fixes" should be run And "mycustombranch" should merge "refs/heads/fixes" from remote "origin" Scenario: Unavailable fork Given the GitHub API server: """ get('/repos/mojombo/jekyll/pulls/77') { json :number => 77, :head => { :ref => "fixes", :repo => nil }, :base => { :repo => { :name => "jekyll", :html_url => "https://github.com/mojombo/jekyll", :owner => { :login => "mojombo" }, } } } """ When I successfully run `hub checkout https://github.com/mojombo/jekyll/pull/77` Then "git fetch origin refs/pull/77/head:fixes" should be run And "git checkout fixes" should be run And "fixes" should merge "refs/pull/77/head" from remote "origin" Scenario: Reuse existing remote for head branch Given the GitHub API server: """ get('/repos/mojombo/jekyll/pulls/77') { json :number => 77, :head => { :ref => "fixes", :repo => { :owner => { :login => "mislav" }, :name => "jekyll", :private => false } }, :base => { :repo => { :name => 'jekyll', :html_url => 'https://github.com/mojombo/jekyll', :owner => { :login => "mojombo" }, } } } """ And the "mislav" remote has url "git://github.com/mislav/jekyll.git" When I successfully run `hub checkout -f https://github.com/mojombo/jekyll/pull/77 -q` Then "git fetch mislav +refs/heads/fixes:refs/remotes/mislav/fixes" should be run And "git checkout -f -b fixes --no-track mislav/fixes -q" should be run And "fixes" should merge "refs/heads/fixes" from remote "mislav" Scenario: Reuse existing remote and branch Given the GitHub API server: """ get('/repos/mojombo/jekyll/pulls/77') { json :number => 77, :head => { :ref => "fixes", :repo => { :owner => { :login => "mislav" }, :name => "jekyll", :private => false } }, :base => { :repo => { :name => 'jekyll', :html_url => 'https://github.com/mojombo/jekyll', :owner => { :login => "mojombo" }, } } } """ And the "mislav" remote has url "git://github.com/mislav/jekyll.git" And I am on the "fixes" branch When I successfully run `hub checkout -f https://github.com/mojombo/jekyll/pull/77 -q` Then "git fetch mislav +refs/heads/fixes:refs/remotes/mislav/fixes" should be run And "git checkout -f fixes -q" should be run And "git merge --ff-only refs/remotes/mislav/fixes" should be run Scenario: Modifiable fork Given the GitHub API server: """ get('/repos/mojombo/jekyll/pulls/77') { json :number => 77, :head => { :ref => "fixes", :repo => { :owner => { :login => "mislav" }, :name => "jekyll", :html_url => "https://github.com/mislav/jekyll.git", :private => false }, }, :base => { :repo => { :name => 'jekyll', :html_url => 'https://github.com/mojombo/jekyll', :owner => { :login => "mojombo" }, } }, :maintainer_can_modify => true } """ And git protocol is preferred When I successfully run `hub checkout -f https://github.com/mojombo/jekyll/pull/77 -q` Then "git fetch origin refs/pull/77/head:fixes" should be run And "git checkout -f fixes -q" should be run And "fixes" should merge "refs/heads/fixes" from remote "git@github.com:mislav/jekyll.git" Scenario: Modifiable fork into current branch Given the GitHub API server: """ get('/repos/mojombo/jekyll/pulls/77') { json :number => 77, :head => { :ref => "fixes", :repo => { :owner => { :login => "mislav" }, :name => "jekyll", :html_url => "https://github.com/mislav/jekyll.git", :private => false }, }, :base => { :repo => { :name => 'jekyll', :html_url => 'https://github.com/mojombo/jekyll', :owner => { :login => "mojombo" }, } }, :maintainer_can_modify => true } """ And git protocol is preferred And I am on the "fixes" branch And there is a git FETCH_HEAD When I successfully run `hub checkout https://github.com/mojombo/jekyll/pull/77` Then "git fetch origin refs/pull/77/head" should be run And "git checkout fixes" should be run And "git merge --ff-only FETCH_HEAD" should be run And "fixes" should merge "refs/heads/fixes" from remote "git@github.com:mislav/jekyll.git" Scenario: Modifiable fork with HTTPS Given the GitHub API server: """ get('/repos/mojombo/jekyll/pulls/77') { json :number => 77, :head => { :ref => "fixes", :repo => { :owner => { :login => "mislav" }, :name => "jekyll", :html_url => "https://github.com/mislav/jekyll.git", :private => false }, }, :base => { :repo => { :name => 'jekyll', :html_url => 'https://github.com/mojombo/jekyll', :owner => { :login => "mojombo" }, } }, :maintainer_can_modify => true } """ When I successfully run `hub checkout -f https://github.com/mojombo/jekyll/pull/77 -q` Then "git fetch origin refs/pull/77/head:fixes" should be run And "git checkout -f fixes -q" should be run And "fixes" should merge "refs/heads/fixes" from remote "https://github.com/mislav/jekyll.git" ================================================ FILE: features/cherry_pick.feature ================================================ Feature: hub cherry-pick Background: Given I am in "git://github.com/rtomayko/ronn.git" git repo And I am "mislav" on github.com with OAuth token "OTOKEN" Scenario: Unchanged When I run `hub cherry-pick a319d88` Then the git command should be unchanged Scenario: From GitHub commit URL When I run `hub cherry-pick https://github.com/rtomayko/ronn/commit/a319d88#comments` Then "git fetch -q --no-tags origin" should be run And "git cherry-pick a319d88" should be run Scenario: From GitHub pull request URL When I run `hub cherry-pick https://github.com/blueyed/ronn/pull/560/commits/a319d88` And "git fetch -q --no-tags origin refs/pull/560/head" should be run And "git cherry-pick a319d88" should be run Scenario: From fork that has existing remote Given the "mislav" remote has url "git@github.com:mislav/ronn.git" When I run `hub cherry-pick https://github.com/mislav/ronn/commit/a319d88` Then "git fetch -q --no-tags mislav" should be run And "git cherry-pick a319d88" should be run Scenario: Using GitHub owner@SHA notation Given the "mislav" remote has url "git@github.com:mislav/ronn.git" When I run `hub cherry-pick mislav@a319d88` Then "git fetch -q --no-tags mislav" should be run And "git cherry-pick a319d88" should be run Scenario: Using GitHub owner@SHA notation that is too short When I run `hub cherry-pick mislav@a319` Then the git command should be unchanged Scenario: Unsupported GitHub owner/repo@SHA notation When I run `hub cherry-pick mislav/ronn@a319d88` Then the git command should be unchanged Scenario: Skips processing if `-m/--mainline` is specified When I run `hub cherry-pick -m 42 mislav@a319d88` Then the git command should be unchanged When I run `hub cherry-pick --mainline 42 mislav@a319d88` Then the git command should be unchanged Scenario: Using GitHub owner@SHA notation with remote add When I run `hub cherry-pick mislav@a319d88` Then "git remote add _hub-cherry-pick https://github.com/mislav/ronn.git" should be run And "git fetch -q --no-tags _hub-cherry-pick" should be run And "git remote rm _hub-cherry-pick" should be run And "git cherry-pick a319d88" should be run Scenario: From fork that doesn't have a remote When I run `hub cherry-pick https://github.com/jingweno/ronn/commit/a319d88` Then "git remote add _hub-cherry-pick https://github.com/jingweno/ronn.git" should be run And "git fetch -q --no-tags _hub-cherry-pick" should be run And "git remote rm _hub-cherry-pick" should be run And "git cherry-pick a319d88" should be run ================================================ FILE: features/ci_status.feature ================================================ Feature: hub ci-status Background: Given I am in "git://github.com/michiels/pencilbox.git" git repo And I am "michiels" on github.com with OAuth token "OTOKEN" Scenario: Fetch commit SHA Given there is a commit named "the_sha" Given the remote commit state of "michiels/pencilbox" "the_sha" is "success" When I run `hub ci-status the_sha` Then the output should contain exactly "success\n" And the exit status should be 0 Scenario: Fetch commit SHA with URL Given there is a commit named "the_sha" Given the remote commit state of "michiels/pencilbox" "the_sha" is "success" When I run `hub ci-status the_sha -v` Then the output should contain exactly: """ ✔︎ continuous-integration/travis-ci/push https://travis-ci.org/michiels/pencilbox/builds/1234567\n """ And the exit status should be 0 Scenario: Multiple statuses with verbose output Given there is a commit named "the_sha" Given the remote commit states of "michiels/pencilbox" "the_sha" are: """ { :state => "error", :statuses => [ { :state => "success", :context => "continuous-integration/travis-ci/push", :target_url => "https://travis-ci.org/michiels/pencilbox/builds/1234567" }, { :state => "success", :context => "continuous-integration/travis-ci/ants", :target_url => "https://travis-ci.org/michiels/pencilbox/builds/1234568" }, { :state => "pending", :context => "continuous-integration/travis-ci/merge", :target_url => nil }, { :state => "error", :context => "whatevs!" }, { :state => "failure", :context => "GitHub CLA", :target_url => "https://cla.github.com/michiels/pencilbox/accept/mislav" }, ] } """ When I run `hub ci-status -v the_sha` Then the output should contain exactly: """ ✖︎ GitHub CLA https://cla.github.com/michiels/pencilbox/accept/mislav ✖︎ whatevs! ● continuous-integration/travis-ci/merge ✔︎ continuous-integration/travis-ci/ants https://travis-ci.org/michiels/pencilbox/builds/1234568 ✔︎ continuous-integration/travis-ci/push https://travis-ci.org/michiels/pencilbox/builds/1234567\n """ And the exit status should be 1 Scenario: Multiple statuses with format string Given there is a commit named "the_sha" Given the remote commit states of "michiels/pencilbox" "the_sha" are: """ { :state => "error", :statuses => [ { :state => "success", :context => "continuous-integration/travis-ci/push", :target_url => "https://travis-ci.org/michiels/pencilbox/builds/1234567" }, { :state => "success", :context => "continuous-integration/travis-ci/ants", :target_url => "https://travis-ci.org/michiels/pencilbox/builds/1234568" }, { :state => "pending", :context => "continuous-integration/travis-ci/merge", :target_url => nil }, { :state => "error", :context => "whatevs!" }, { :state => "failure", :context => "GitHub CLA", :target_url => "https://cla.github.com/michiels/pencilbox/accept/mislav" }, ] } """ When I run `hub ci-status the_sha --format '%S: %t (%U)%n'` Then the output should contain exactly: """ failure: GitHub CLA (https://cla.github.com/michiels/pencilbox/accept/mislav) error: whatevs! () pending: continuous-integration/travis-ci/merge () success: continuous-integration/travis-ci/ants (https://travis-ci.org/michiels/pencilbox/builds/1234568) success: continuous-integration/travis-ci/push (https://travis-ci.org/michiels/pencilbox/builds/1234567)\n """ And the exit status should be 1 Scenario: Exit status 1 for 'error' and 'failure' Given the remote commit state of "michiels/pencilbox" "HEAD" is "error" When I run `hub ci-status` Then the exit status should be 1 And the output should contain exactly "error\n" Scenario: Use HEAD when no sha given Given the remote commit state of "michiels/pencilbox" "HEAD" is "pending" When I run `hub ci-status` Then the exit status should be 2 And the output should contain exactly "pending\n" Scenario: Exit status 3 for no statuses available Given there is a commit named "the_sha" Given the remote commit state of "michiels/pencilbox" "the_sha" is nil When I run `hub ci-status the_sha` Then the output should contain exactly "no status\n" And the exit status should be 3 Scenario: Exit status 3 for no statuses available without URL Given there is a commit named "the_sha" Given the remote commit state of "michiels/pencilbox" "the_sha" is nil When I run `hub ci-status -v the_sha` Then the output should contain exactly "no status\n" And the exit status should be 3 Scenario: Abort with message when invalid ref given When I run `hub ci-status this-is-an-invalid-ref` Then the exit status should be 1 And the output should contain exactly "Aborted: no revision could be determined from 'this-is-an-invalid-ref'\n" Scenario: Non-GitHub repo Given the "origin" remote has url "mygh:Manganeez/repo.git" When I run `hub ci-status` Then the stderr should contain exactly: """ Aborted: could not find any git remote pointing to a GitHub repository\n """ And the exit status should be 1 Scenario: Enterprise CI statuses Given the "origin" remote has url "git@git.my.org:michiels/pencilbox.git" And I am "michiels" on git.my.org with OAuth token "FITOKEN" And "git.my.org" is a whitelisted Enterprise host Given there is a commit named "the_sha" Given the remote commit state of "git.my.org/michiels/pencilbox" "the_sha" is "success" When I successfully run `hub ci-status the_sha` Then the output should contain exactly "success\n" Scenario: If alias named ci-status exists, it should not be expanded. Given there is a commit named "the_sha" Given the remote commit state of "michiels/pencilbox" "the_sha" is "success" When I successfully run `git config --global alias.ci-status "ci-status -v"` And I successfully run `hub ci-status the_sha` Then the output should contain exactly "success\n" Scenario: Has Checks Given there is a commit named "the_sha" And the GitHub API server: """ get('/repos/michiels/pencilbox/commits/:sha/status') { json({ :state => "success", :statuses => [ { :state => "success", :context => "travis-ci", :target_url => "the://url"} ] }) } get('/repos/michiels/pencilbox/commits/:sha/check-runs') { json({ :check_runs => [ { :status => "completed", :conclusion => "action_required", :name => "check 1", :html_url => "the://url" }, { :status => "queued", :conclusion => "", :name => "check 2", :html_url => "the://url" }, ] }) } """ When I run `hub ci-status the_sha` Then the output should contain exactly "action_required\n" And the exit status should be 1 Scenario: Older Enterprise version doesn't have Checks Given the "origin" remote has url "git@git.my.org:michiels/pencilbox.git" And I am "michiels" on git.my.org with OAuth token "FITOKEN" And "git.my.org" is a whitelisted Enterprise host And there is a commit named "the_sha" And the GitHub API server: """ get('/api/v3/repos/michiels/pencilbox/commits/:sha/status', :host_name => 'git.my.org') { json({ :state => "success", :statuses => [ { :state => "success", :context => "travis-ci", :target_url => "the://url"} ] }) } get('/api/v3/repos/michiels/pencilbox/commits/:sha/check-runs', :host_name => 'git.my.org') { status 403 json :message => "Must have admin rights to Repository.", :documentation_url => "https://developer.github.com/enterprise/2.13/v3/" } """ When I successfully run `hub ci-status the_sha` Then the output should contain exactly "success\n" ================================================ FILE: features/clone.feature ================================================ Feature: hub clone Background: Given I am "mislav" on github.com with OAuth token "OTOKEN" Scenario: Clone a public repo Given the GitHub API server: """ get('/repos/rtomayko/ronn') { json :private => false, :name => 'ronn', :owner => { :login => 'rtomayko' }, :permissions => { :push => false } } """ When I successfully run `hub clone rtomayko/ronn` Then it should clone "https://github.com/rtomayko/ronn.git" And the output should not contain anything Scenario: Clone a public repo with period in name Given the GitHub API server: """ get('/repos/hookio/hook.js') { json :private => false, :name => 'hook.js', :owner => { :login => 'hookio' }, :permissions => { :push => false } } """ When I successfully run `hub clone hookio/hook.js` Then it should clone "https://github.com/hookio/hook.js.git" And the output should not contain anything Scenario: Clone a public repo that starts with a period Given the GitHub API server: """ get('/repos/zhuangya/.vim') { json :private => false, :name => '.vim', :owner => { :login => 'zhuangya' }, :permissions => { :push => false } } """ When I successfully run `hub clone zhuangya/.vim` Then it should clone "https://github.com/zhuangya/.vim.git" And the output should not contain anything Scenario: Clone a repo even if same-named directory exists Given the GitHub API server: """ get('/repos/rtomayko/ronn') { json :private => false, :name => 'ronn', :owner => { :login => 'rtomayko' }, :permissions => { :push => false } } """ And a directory named "rtomayko/ronn" When I successfully run `hub clone rtomayko/ronn` Then it should clone "https://github.com/rtomayko/ronn.git" And the output should not contain anything Scenario: Clone a public repo with git Given git protocol is preferred Given the GitHub API server: """ get('/repos/rtomayko/ronn') { json :private => false, :name => 'ronn', :owner => { :login => 'rtomayko' }, :permissions => { :push => false } } """ When I successfully run `hub clone rtomayko/ronn` Then it should clone "git://github.com/rtomayko/ronn.git" And the output should not contain anything Scenario: Clone a public repo with HTTPS Given the GitHub API server: """ get('/repos/rtomayko/ronn') { json :private => false, :name => 'ronn', :owner => { :login => 'rtomayko' }, :permissions => { :push => false } } """ When I successfully run `hub clone rtomayko/ronn` Then it should clone "https://github.com/rtomayko/ronn.git" And the output should not contain anything Scenario: Clone command aliased Given the GitHub API server: """ get('/repos/rtomayko/ronn') { json :private => false, :name => 'ronn', :owner => { :login => 'rtomayko' }, :permissions => { :push => false } } """ When I successfully run `git config --global alias.c "clone --bare"` And I successfully run `hub c rtomayko/ronn` Then "git clone --bare https://github.com/rtomayko/ronn.git" should be run And the output should not contain anything Scenario: Unchanged public clone When I successfully run `hub clone git://github.com/rtomayko/ronn.git` Then the git command should be unchanged Scenario: Unchanged public clone with path When I successfully run `hub clone git://github.com/rtomayko/ronn.git ronnie` Then the git command should be unchanged And the output should not contain anything Scenario: Unchanged private clone When I successfully run `hub clone git@github.com:rtomayko/ronn.git` Then the git command should be unchanged And the output should not contain anything Scenario: Unchanged clone with complex arguments When I successfully run `hub clone --template=one/two git://github.com/defunkt/resque.git --origin master resquetastic` Then the git command should be unchanged And the output should not contain anything Scenario: Unchanged local clone When I successfully run `hub clone ./dotfiles` Then the git command should be unchanged And the output should not contain anything Scenario: Unchanged local clone with destination Given a directory named ".git" When I successfully run `hub clone -l . ../copy` Then the git command should be unchanged And the output should not contain anything Scenario: Unchanged local clone from bare repo Given a bare git repo in "rtomayko/ronn" When I successfully run `hub clone rtomayko/ronn` Then the git command should be unchanged And the output should not contain anything Scenario: Unchanged clone with host alias When I successfully run `hub clone shortcut:git/repo.git` Then the git command should be unchanged And the output should not contain anything Scenario: Preview cloning a private repo Given the GitHub API server: """ get('/repos/rtomayko/ronn') { json :private => false, :name => 'ronn', :owner => { :login => 'rtomayko' }, :permissions => { :push => false } } """ When I successfully run `hub --noop clone rtomayko/ronn` Then the output should contain exactly "git clone https://github.com/rtomayko/ronn.git\n" But it should not clone anything Scenario: Clone a private repo Given the GitHub API server: """ get('/repos/rtomayko/ronn') { json :private => false, :name => 'ronn', :owner => { :login => 'rtomayko' }, :permissions => { :push => false } } """ When I successfully run `hub clone -p rtomayko/ronn` Then it should clone "https://github.com/rtomayko/ronn.git" And the output should not contain anything Scenario: Clone my repo Given the GitHub API server: """ get('/repos/mislav/dotfiles') { json :private => false, :name => 'dotfiles', :owner => { :login => 'mislav' }, :permissions => { :push => true } } """ When I successfully run `hub clone dotfiles` Then it should clone "https://github.com/mislav/dotfiles.git" And the output should not contain anything Scenario: Clone my repo that doesn't exist Given the GitHub API server: """ get('/repos/mislav/dotfiles') { status 404 } """ When I run `hub clone dotfiles` Then the exit status should be 1 And the stdout should contain exactly "" And the stderr should contain exactly "Error: repository mislav/dotfiles doesn't exist\n" And it should not clone anything Scenario: Clone my repo with arguments Given the GitHub API server: """ get('/repos/mislav/dotfiles') { json :private => false, :name => 'dotfiles', :owner => { :login => 'mislav' }, :permissions => { :push => true } } """ When I successfully run `hub clone --bare -o master dotfiles` Then "git clone --bare -o master https://github.com/mislav/dotfiles.git" should be run And the output should not contain anything Scenario: Clone repo to which I have push access to Given the GitHub API server: """ get('/repos/sstephenson/rbenv') { json :private => false, :name => 'rbenv', :owner => { :login => 'sstephenson' }, :permissions => { :push => true } } """ And git protocol is preferred When I successfully run `hub clone sstephenson/rbenv` Then "git clone git@github.com:sstephenson/rbenv.git" should be run And the output should not contain anything Scenario: Preview cloning a repo I have push access to Given the GitHub API server: """ get('/repos/sstephenson/rbenv') { json :private => false, :name => 'rbenv', :owner => { :login => 'sstephenson' }, :permissions => { :push => true } } """ And git protocol is preferred When I successfully run `hub --noop clone sstephenson/rbenv` Then the output should contain exactly "git clone git@github.com:sstephenson/rbenv.git\n" But it should not clone anything Scenario: Clone my Enterprise repo Given I am "mifi" on git.my.org with OAuth token "FITOKEN" And $GITHUB_HOST is "git.my.org" Given the GitHub API server: """ get('/api/v3/repos/myorg/myrepo') { json :private => true, :name => 'myrepo', :owner => { :login => 'myorg' }, :permissions => { :push => false } } """ When I successfully run `hub clone myorg/myrepo` Then it should clone "https://git.my.org/myorg/myrepo.git" And the output should not contain anything Scenario: Clone from existing directory is a local clone Given a directory named "dotfiles/.git" When I successfully run `hub clone dotfiles` Then the git command should be unchanged And the output should not contain anything Scenario: Clone from git bundle is a local clone Given a git bundle named "my-bundle" When I successfully run `hub clone my-bundle` Then the git command should be unchanged And the output should not contain anything Scenario: Clone a wiki Given the GitHub API server: """ get('/repos/rtomayko/ronn') { json :private => false, :name => 'ronin', :owner => { :login => 'RTomayko' }, :permissions => { :push => false }, :has_wiki => true } """ When I successfully run `hub clone rtomayko/ronn.wiki` Then it should clone "https://github.com/RTomayko/ronin.wiki.git" And the output should not contain anything Scenario: Clone a nonexisting wiki Given the GitHub API server: """ get('/repos/rtomayko/ronn') { json :private => false, :name => 'ronin', :owner => { :login => 'RTomayko' }, :permissions => { :push => false }, :has_wiki => false } """ When I run `hub clone rtomayko/ronn.wiki` Then the exit status should be 1 And the stdout should contain exactly "" And the stderr should contain exactly "Error: RTomayko/ronin doesn't have a wiki\n" And it should not clone anything Scenario: Clone a redirected repo Given the GitHub API server: """ get('/repos/rtomayko/ronn') { redirect 'https://api.github.com/repositories/12345', 301 } get('/repositories/12345', :host_name => 'api.github.com') { halt 401 unless request.env['HTTP_AUTHORIZATION'] == 'token OTOKEN' json :private => false, :name => 'ronin', :owner => { :login => 'RTomayko' }, :permissions => { :push => false } } """ When I successfully run `hub clone rtomayko/ronn` Then it should clone "https://github.com/RTomayko/ronin.git" And the output should not contain anything ================================================ FILE: features/compare.feature ================================================ Feature: hub compare Background: Given I am in "git://github.com/mislav/dotfiles.git" git repo And I am "mislav" on github.com with OAuth token "OTOKEN" Scenario: Compare branch When I successfully run `hub compare refactor` Then the output should not contain anything And "open https://github.com/mislav/dotfiles/compare/refactor" should be run Scenario: Compare complex branch When I successfully run `hub compare feature/foo` Then the output should not contain anything And "open https://github.com/mislav/dotfiles/compare/feature/foo" should be run Scenario: Compare branch with funky characters When I successfully run `hub compare 'my#branch!with.special+chars'` Then the output should not contain anything And "open https://github.com/mislav/dotfiles/compare/my%23branch!with.special%2Bchars" should be run Scenario: No args, no upstream When I run `hub compare` Then the exit status should be 1 And the stderr should contain exactly "the current branch 'master' doesn't seem pushed to a remote\n" Scenario: Can't compare default branch to self Given the default branch for "origin" is "develop" And I am on the "develop" branch with upstream "origin/develop" When I run `hub compare` Then the exit status should be 1 And the stderr should contain exactly "the branch to compare 'develop' is the default branch\n" Scenario: No args, has upstream branch Given I am on the "feature" branch with upstream "origin/experimental" And git "push.default" is set to "upstream" When I successfully run `hub compare` Then the output should not contain anything And "open https://github.com/mislav/dotfiles/compare/experimental" should be run Scenario: Current branch has funky characters Given I am on the "feature" branch with upstream "origin/my#branch!with.special+chars" And git "push.default" is set to "upstream" When I successfully run `hub compare` Then the output should not contain anything And "open https://github.com/mislav/dotfiles/compare/my%23branch!with.special%2Bchars" should be run Scenario: Current branch pushed to fork Given I am "monalisa" on github.com with OAuth token "MONATOKEN" And the "monalisa" remote has url "git@github.com:monalisa/dotfiles.git" And I am on the "topic" branch pushed to "monalisa/topic" When I successfully run `hub compare` Then "open https://github.com/mislav/dotfiles/compare/monalisa:topic" should be run Scenario: Current branch with full URL in upstream configuration Given I am on the "local-topic" branch When I successfully run `git config branch.local-topic.remote https://github.com/monalisa/dotfiles.git` When I successfully run `git config branch.local-topic.merge refs/remotes/remote-topic` When I successfully run `hub compare` Then "open https://github.com/mislav/dotfiles/compare/monalisa:remote-topic" should be run Scenario: Compare range When I successfully run `hub compare 1.0...fix` Then the output should not contain anything And "open https://github.com/mislav/dotfiles/compare/1.0...fix" should be run Scenario: Output URL without opening the browser When I successfully run `hub compare -u 1.0...fix` Then "open https://github.com/mislav/dotfiles/compare/1.0...fix" should not be run And the stdout should contain exactly: """ https://github.com/mislav/dotfiles/compare/1.0...fix\n """ Scenario: Compare base in branch that is not master Given I am on the "feature" branch with upstream "origin/experimental" And git "push.default" is set to "upstream" When I successfully run `hub compare -b master` Then the output should not contain anything And "open https://github.com/mislav/dotfiles/compare/master...experimental" should be run Scenario: Compare base in master branch Given I am on the "master" branch with upstream "origin/master" And git "push.default" is set to "upstream" When I successfully run `hub compare -b experimental` Then the output should not contain anything And "open https://github.com/mislav/dotfiles/compare/experimental...master" should be run Scenario: Compare base with same branch as the current branch Given I am on the "feature" branch with upstream "origin/experimental" And git "push.default" is set to "upstream" When I run `hub compare -b experimental` Then "open https://github.com/mislav/dotfiles/compare/experimental...experimental" should not be run And the exit status should be 1 And the stderr should contain exactly "the branch to compare 'experimental' is the same as --base\n" Scenario: Compare base with parameters Given I am on the "master" branch with upstream "origin/master" When I run `hub compare -b master experimental..master` Then "open https://github.com/mislav/dotfiles/compare/experimental...master" should not be run And the exit status should be 1 And the stderr should contain "Usage: hub compare" Scenario: Compare 2-dots range for tags When I successfully run `hub compare 1.0..fix` Then the output should not contain anything And "open https://github.com/mislav/dotfiles/compare/1.0...fix" should be run Scenario: Compare 2-dots range for SHAs When I successfully run `hub compare 1234abc..3456cde` Then the output should not contain anything And "open https://github.com/mislav/dotfiles/compare/1234abc...3456cde" should be run Scenario: Compare 2-dots range with "user:repo" notation When I successfully run `hub compare henrahmagix:master..2b10927` Then the output should not contain anything And "open https://github.com/mislav/dotfiles/compare/henrahmagix:master...2b10927" should be run Scenario: Compare 2-dots range with slashes in branch names When I successfully run `hub compare one/foo..two/bar/baz` Then the output should not contain anything And "open https://github.com/mislav/dotfiles/compare/one/foo...two/bar/baz" should be run Scenario: Complex range is unchanged When I successfully run `hub compare @{a..b}..@{c..d}` Then the output should not contain anything And "open https://github.com/mislav/dotfiles/compare/@{a..b}..@{c..d}" should be run Scenario: Compare wiki Given the "origin" remote has url "git://github.com/mislav/dotfiles.wiki.git" When I successfully run `hub compare 1.0..fix` Then the output should not contain anything And "open https://github.com/mislav/dotfiles/wiki/_compare/1.0...fix" should be run Scenario: Compare fork When I successfully run `hub compare anotheruser feature` Then the output should not contain anything And "open https://github.com/anotheruser/dotfiles/compare/feature" should be run Scenario: Enterprise repo over HTTP Given the "origin" remote has url "git://git.my.org/mislav/dotfiles.git" And I am "mislav" on http://git.my.org with OAuth token "FITOKEN" And "git.my.org" is a whitelisted Enterprise host When I successfully run `hub compare refactor` Then the output should not contain anything And "open http://git.my.org/mislav/dotfiles/compare/refactor" should be run Scenario: Enterprise repo with explicit upstream project Given the "origin" remote has url "git://git.my.org/mislav/dotfiles.git" And I am "mislav" on git.my.org with OAuth token "FITOKEN" And "git.my.org" is a whitelisted Enterprise host When I successfully run `hub compare fehmicansaglam a..b` Then the output should not contain anything And "open https://git.my.org/fehmicansaglam/dotfiles/compare/a...b" should be run Scenario: Compare in non-GitHub repo Given the "origin" remote has url "git@bitbucket.org:mislav/dotfiles.git" And I am on the "feature" branch When I run `hub compare` Then the stdout should contain exactly "" And the stderr should contain exactly: """ Aborted: could not find any git remote pointing to a GitHub repository\n """ And the exit status should be 1 Scenario: Comparing two branches while not on a local branch Given I am in detached HEAD And I run `hub compare refactor...master` Then the exit status should be 0 And the output should not contain anything And "open https://github.com/mislav/dotfiles/compare/refactor...master" should be run ================================================ FILE: features/create.feature ================================================ Feature: hub create Background: Given I am in "dotfiles" git repo And I am "mislav" on github.com with OAuth token "OTOKEN" Scenario: Create repo Given the GitHub API server: """ post('/user/repos') { assert :private => false status 201 json :full_name => 'mislav/dotfiles' } """ When I successfully run `hub create` Then the url for "origin" should be "https://github.com/mislav/dotfiles.git" And the output should contain exactly "https://github.com/mislav/dotfiles\n" Scenario: Create private repo Given the GitHub API server: """ post('/user/repos') { assert :private => true status 201 json :full_name => 'mislav/dotfiles' } """ And git protocol is preferred When I successfully run `hub create -p` Then the url for "origin" should be "git@github.com:mislav/dotfiles.git" Scenario: Alternate origin remote name Given the GitHub API server: """ post('/user/repos') { status 201 json :full_name => 'mislav/dotfiles' } """ When I successfully run `hub create --remote-name=work` Then the url for "work" should be "https://github.com/mislav/dotfiles.git" And there should be no "origin" remote Scenario: Create in organization Given the GitHub API server: """ post('/orgs/acme/repos') { status 201 json :full_name => 'acme/dotfiles' } """ When I successfully run `hub create acme/dotfiles` Then the url for "origin" should be "https://github.com/acme/dotfiles.git" And the output should contain exactly "https://github.com/acme/dotfiles\n" Scenario: Creating repo failed Given the GitHub API server: """ post('/user/repos') { status 500 } """ When I run `hub create` Then the stderr should contain "Error creating repository: Internal Server Error (HTTP 500)" And the exit status should be 1 And there should be no "origin" remote Scenario: With custom name Given the GitHub API server: """ post('/user/repos') { assert :name => 'myconfig' status 201 json :full_name => 'mislav/myconfig' } """ When I successfully run `hub create myconfig` Then the url for "origin" should be "https://github.com/mislav/myconfig.git" Scenario: With description and homepage Given the GitHub API server: """ post('/user/repos') { assert :description => 'mydesc', :homepage => 'http://example.com' status 201 json :full_name => 'mislav/dotfiles' } """ When I successfully run `hub create -d mydesc -h http://example.com` Then the url for "origin" should be "https://github.com/mislav/dotfiles.git" Scenario: Not in git repo Given the current dir is not a repo When I run `hub create` Then the stderr should contain "'create' must be run from inside a git repository" And the exit status should be 1 Scenario: Cannot create from bare repo Given the current dir is not a repo And I run `git -c init.defaultBranch=main init --bare` When I run `hub create` Then the stderr should contain exactly "unable to determine git working directory\n" And the exit status should be 1 Scenario: Origin remote already exists Given the GitHub API server: """ post('/user/repos') { status 201 json :full_name => 'mislav/dotfiles' } """ And the "origin" remote has url "git://github.com/mislav/dotfiles.git" When I successfully run `hub create` Then the url for "origin" should be "git://github.com/mislav/dotfiles.git" And the output should contain exactly "https://github.com/mislav/dotfiles\n" Scenario: Unrelated origin remote already exists Given the GitHub API server: """ post('/user/repos') { status 201 json :full_name => 'mislav/dotfiles' } """ And the "origin" remote has url "git://example.com/unrelated.git" When I successfully run `hub create` Then the url for "origin" should be "git://example.com/unrelated.git" And the stdout should contain exactly "https://github.com/mislav/dotfiles\n" And the stderr should contain exactly: """ A git remote named 'origin' already exists and is set to push to 'git://example.com/unrelated.git'.\n """ Scenario: Another remote already exists Given the GitHub API server: """ post('/user/repos') { status 201 json :full_name => 'mislav/dotfiles' } """ And the "github" remote has url "git://github.com/mislav/dotfiles.git" When I successfully run `hub create` Then the url for "origin" should be "https://github.com/mislav/dotfiles.git" Scenario: GitHub repo already exists Given the GitHub API server: """ get('/repos/mislav/dotfiles') { json :full_name => 'mislav/dotfiles' } """ When I successfully run `hub create` Then the output should contain "Existing repository detected\n" And the url for "origin" should be "https://github.com/mislav/dotfiles.git" Scenario: GitHub repo already exists and is not private Given the GitHub API server: """ get('/repos/mislav/dotfiles') { json :full_name => 'mislav/dotfiles', :private => false } """ When I run `hub create -p` Then the output should contain "Repository 'mislav/dotfiles' already exists and is public\n" And the exit status should be 1 And there should be no "origin" remote Scenario: GitHub repo already exists and is private Given the GitHub API server: """ get('/repos/mislav/dotfiles') { json :full_name => 'mislav/dotfiles', :private => true } """ And git protocol is preferred When I successfully run `hub create -p` Then the url for "origin" should be "git@github.com:mislav/dotfiles.git" Scenario: Renamed GitHub repo already exists Given the GitHub API server: """ get('/repos/mislav/dotfiles') { redirect 'https://api.github.com/repositories/12345', 301 } get('/repositories/12345') { json :full_name => 'mislav/DOTfiles' } """ When I successfully run `hub create` And the url for "origin" should be "https://github.com/mislav/DOTfiles.git" Scenario: Renamed GitHub repo is unrelated Given the GitHub API server: """ get('/repos/mislav/dotfiles') { redirect 'https://api.github.com/repositories/12345', 301 } get('/repositories/12345') { json :full_name => 'mislav/old-dotfiles' } post('/user/repos') { status 201 json :full_name => 'mislav/mydotfiles' } """ When I successfully run `hub create` And the url for "origin" should be "https://github.com/mislav/mydotfiles.git" Scenario: API response changes the clone URL Given the GitHub API server: """ post('/user/repos') { status 201 json :full_name => 'Mooslav/myconfig' } """ When I successfully run `hub create` Then the url for "origin" should be "https://github.com/Mooslav/myconfig.git" And the output should contain exactly "https://github.com/Mooslav/myconfig\n" Scenario: Open new repository in web browser Given the GitHub API server: """ post('/user/repos') { status 201 json :full_name => 'Mooslav/myconfig' } """ When I successfully run `hub create -o` Then the output should contain exactly "" And "open https://github.com/Mooslav/myconfig" should be run Scenario: Current directory contains spaces Given I am in "my dot files" git repo Given the GitHub API server: """ post('/user/repos') { assert :name => 'my-dot-files' status 201 json :full_name => 'mislav/my-dot-files' } """ When I successfully run `hub create` Then the url for "origin" should be "https://github.com/mislav/my-dot-files.git" Scenario: Verbose API output Given the GitHub API server: """ get('/repos/mislav/dotfiles') { status 404 } post('/user/repos') { response['location'] = 'http://disney.com' status 201 json :full_name => 'mislav/dotfiles' } """ And $HUB_VERBOSE is "on" When I successfully run `hub create` Then the stderr should contain: """ > GET https://api.github.com/repos/mislav/dotfiles > Authorization: token [REDACTED] > Accept: application/vnd.github.v3+json;charset=utf-8 < HTTP 404 """ And the stderr should contain: """ > POST https://api.github.com/user/repos > Authorization: token [REDACTED] """ And the stderr should contain: """ < HTTP 201 < Location: http://disney.com {"full_name":"mislav/dotfiles"}\n """ Scenario: Create Enterprise repo Given I am "nsartor" on git.my.org with OAuth token "FITOKEN" Given the GitHub API server: """ post('/api/v3/user/repos', :host_name => 'git.my.org') { assert :private => false status 201 json :full_name => 'nsartor/dotfiles' } """ And $GITHUB_HOST is "git.my.org" When I successfully run `hub create` Then the url for "origin" should be "https://git.my.org/nsartor/dotfiles.git" And the output should contain exactly "https://git.my.org/nsartor/dotfiles\n" Scenario: Invalid GITHUB_HOST Given I am "nsartor" on {} with OAuth token "FITOKEN" And $GITHUB_HOST is "{}" When I run `hub create` Then the exit status should be 1 And the stderr should contain exactly: """ invalid hostname: "{}"\n """ ================================================ FILE: features/delete.feature ================================================ Feature: hub delete Background: Given I am "andreasbaumann" on github.com with OAuth token "OTOKEN" Scenario: No argument in current repo Given I am in "git://github.com/github/hub.git" git repo When I run `hub delete` Then the exit status should be 1 And the stderr should contain exactly: """ Usage: hub delete [-y] [/]\n """ Scenario: Successful confirmation Given the GitHub API server: """ delete('/repos/andreasbaumann/my-repo') { status 204 } """ When I run `hub delete my-repo` interactively And I type "yes" Then the exit status should be 0 And the output should contain: """ Really delete repository 'andreasbaumann/my-repo' (yes/N)? """ And the output should contain: """ Deleted repository 'andreasbaumann/my-repo'. """ Scenario: Org repo Given the GitHub API server: """ delete('/repos/our-org/my-repo') { status 204 } """ When I run `hub delete our-org/my-repo` interactively And I type "yes" Then the exit status should be 0 And the output should contain: """ Really delete repository 'our-org/my-repo' (yes/N)? """ And the output should contain: """ Deleted repository 'our-org/my-repo'. """ Scenario: Invalid confirmation When I run `hub delete my-repo` interactively And I type "y" Then the exit status should be 1 And the output should contain: """ Really delete repository 'andreasbaumann/my-repo' (yes/N)? """ And the stderr should contain exactly: """ Please type 'yes' for confirmation.\n """ Scenario: HTTP 403 Given the GitHub API server: """ delete('/repos/andreasbaumann/my-repo') { status 403 } """ When I run `hub delete -y my-repo` Then the exit status should be 1 And the stderr should contain: """ Please edit the token used for hub at https://github.com/settings/tokens and verify that the `delete_repo` scope is enabled. """ Scenario: HTTP 403 on GitHub Enterprise Given I am "mislav" on git.my.org with OAuth token "FITOKEN" And $GITHUB_HOST is "git.my.org" Given the GitHub API server: """ delete('/api/v3/repos/mislav/my-repo', :host_name => 'git.my.org') { status 403 } """ When I run `hub delete -y my-repo` Then the exit status should be 1 And the stderr should contain: """ Please edit the token used for hub at https://git.my.org/settings/tokens and verify that the `delete_repo` scope is enabled. """ ================================================ FILE: features/fetch.feature ================================================ Feature: hub fetch Background: Given I am in "dotfiles" git repo And the "origin" remote has url "git://github.com/evilchelu/dotfiles.git" And I am "mislav" on github.com with OAuth token "OTOKEN" Scenario: Fetch existing remote When I successfully run `hub fetch origin` Then the git command should be unchanged And the output should not contain anything Scenario: Fetch existing remote from non-GitHub source Given the "origin" remote has url "ssh://dev@codeserver.dev.xxx.drush.in/~/repository.git" When I successfully run `hub fetch origin` Then the git command should be unchanged And the output should not contain anything Scenario: Fetch from non-GitHub source via refspec Given the "origin" remote has url "ssh://dev@codeserver.dev.xxx.drush.in/~/repository.git" When I successfully run `hub fetch ssh://myusername@a.specific.server:1234/existing-project/gerrit-project-name refs/changes/16/6116/1` Then the git command should be unchanged And the output should not contain anything Scenario: Fetch from local bundle Given the GitHub API server: """ get('/repos/mislav/dotfiles') { json :private => false, :permissions => { :push => false } } """ And a git bundle named "mislav" When I successfully run `hub fetch mislav` Then the git command should be unchanged And the output should not contain anything And there should be no "mislav" remote Scenario: Creates new remote Given the GitHub API server: """ get('/repos/mislav/dotfiles') { json :private => false, :permissions => { :push => false } } """ When I successfully run `hub fetch mislav` Then "git fetch mislav" should be run And the url for "mislav" should be "https://github.com/mislav/dotfiles.git" And the output should not contain anything Scenario: Creates new remote with git Given git protocol is preferred Given the GitHub API server: """ get('/repos/mislav/dotfiles') { json :private => false, :permissions => { :push => false } } """ When I successfully run `hub fetch mislav` Then "git fetch mislav" should be run And the url for "mislav" should be "git://github.com/mislav/dotfiles.git" And the output should not contain anything Scenario: Owner name with dash Given the GitHub API server: """ get('/repos/ankit-maverick/dotfiles') { json :private => false, :permissions => { :push => false } } """ When I successfully run `hub fetch ankit-maverick` Then "git fetch ankit-maverick" should be run And the url for "ankit-maverick" should be "https://github.com/ankit-maverick/dotfiles.git" And the output should not contain anything Scenario: Private repo Given the GitHub API server: """ get('/repos/mislav/dotfiles') { json :private => true, :permissions => { :push => false } } """ And git protocol is preferred When I successfully run `hub fetch mislav` Then "git fetch mislav" should be run And the url for "mislav" should be "git@github.com:mislav/dotfiles.git" And the output should not contain anything Scenario: Writeable repo Given the GitHub API server: """ get('/repos/mislav/dotfiles') { json :private => false, :permissions => { :push => true } } """ And git protocol is preferred When I successfully run `hub fetch mislav` Then "git fetch mislav" should be run And the url for "mislav" should be "git@github.com:mislav/dotfiles.git" And the output should not contain anything Scenario: Fetch with options Given the GitHub API server: """ get('/repos/mislav/dotfiles') { json :private => false, :permissions => { :push => false } } """ When I successfully run `hub fetch --depth=1 mislav` Then "git fetch --depth=1 mislav" should be run Scenario: Fetch multiple Given the GitHub API server: """ get('/repos/:owner/dotfiles') { json :private => false, :permissions => { :push => false } } """ When I successfully run `hub fetch --multiple mislav rtomayko` Then "git fetch --multiple mislav rtomayko" should be run And the url for "mislav" should be "https://github.com/mislav/dotfiles.git" And the url for "rtomayko" should be "https://github.com/rtomayko/dotfiles.git" Scenario: Fetch multiple with filtering Given the GitHub API server: """ get('/repos/mislav/dotfiles') { json :private => false, :permissions => { :push => false } } """ When I successfully run `git config remotes.mygrp "foo bar"` When I successfully run `hub fetch --multiple origin mislav mygrp https://example.com typo` Then "git fetch --multiple origin mislav mygrp https://example.com typo" should be run And the url for "mislav" should be "https://github.com/mislav/dotfiles.git" But there should be no "mygrp" remote And there should be no "typo" remote Scenario: Fetch multiple comma-separated Given the GitHub API server: """ get('/repos/:owner/dotfiles') { json :private => false, :permissions => { :push => false } } """ When I successfully run `hub fetch mislav,rtomayko,dustinleblanc` Then "git fetch --multiple mislav rtomayko dustinleblanc" should be run And the url for "mislav" should be "https://github.com/mislav/dotfiles.git" And the url for "rtomayko" should be "https://github.com/rtomayko/dotfiles.git" And the url for "dustinleblanc" should be "https://github.com/dustinleblanc/dotfiles.git" Scenario: Doesn't create a new remote if repo doesn't exist on GitHub Given the GitHub API server: """ get('/repos/mislav/dotfiles') { status 404 } """ When I successfully run `hub fetch mislav` Then the git command should be unchanged And there should be no "mislav" remote ================================================ FILE: features/fish_completion.feature ================================================ @completion Feature: fish tab-completion Background: Given my shell is fish Scenario: "pu" matches multiple commands including "pull-request" When I type "git pu" and press Then the command should not expand When I press again Then the completion menu should offer "pull push pull-request" unsorted Scenario: "ci-" expands to "ci-status" When I type "git ci-" and press Then the command should expand to "git ci-status" Scenario: Offers pull-request flags When I type "git pull-request -" and press When I press again Then the completion menu should offer "-F -b -f -h -m -a -M -l -o --browse -p --help" unsorted Scenario: Browse to issues When I type "git browse -- i" and press Then the command should expand to "git browse -- issues" ================================================ FILE: features/fork.feature ================================================ Feature: hub fork Background: Given I am in "dotfiles" git repo And the "origin" remote has url "git://github.com/evilchelu/dotfiles.git" And I am "mislav" on github.com with OAuth token "OTOKEN" Scenario: Fork the repository Given the GitHub API server: """ before { halt 400 unless request.env['HTTP_X_ORIGINAL_SCHEME'] == 'https' halt 401 unless request.env['HTTP_AUTHORIZATION'] == 'token OTOKEN' } get('/repos/mislav/dotfiles', :host_name => 'api.github.com') { 404 } post('/repos/evilchelu/dotfiles/forks', :host_name => 'api.github.com') { assert :organization => nil status 202 json :name => 'dotfiles', :owner => { :login => 'mislav' } } """ When I successfully run `hub fork` Then the output should contain exactly "new remote: mislav\n" And "git remote add -f mislav https://github.com/evilchelu/dotfiles.git" should be run And "git remote set-url mislav https://github.com/mislav/dotfiles.git" should be run And the url for "mislav" should be "https://github.com/mislav/dotfiles.git" Scenario: Fork the repository with new remote name specified Given git protocol is preferred Given the GitHub API server: """ get('/repos/mislav/dotfiles') { 404 } post('/repos/evilchelu/dotfiles/forks') { assert :organization => nil status 202 json :name => 'dotfiles', :owner => { :login => 'mislav' } } """ When I successfully run `hub fork --remote-name=origin` Then the output should contain exactly: """ renaming existing "origin" remote to "upstream" new remote: origin\n """ And "git remote add -f origin git://github.com/evilchelu/dotfiles.git" should be run And "git remote set-url origin git@github.com:mislav/dotfiles.git" should be run And the url for "origin" should be "git@github.com:mislav/dotfiles.git" And the url for "upstream" should be "git://github.com/evilchelu/dotfiles.git" Scenario: Fork the repository with redirect Given git protocol is preferred Given the GitHub API server: """ before { halt 400 unless request.env['HTTP_X_ORIGINAL_SCHEME'] == 'https' halt 401 unless request.env['HTTP_AUTHORIZATION'] == 'token OTOKEN' } get('/repos/mislav/dotfiles', :host_name => 'api.github.com') { 404 } post('/repos/evilchelu/dotfiles/forks', :host_name => 'api.github.com') { redirect 'https://api.github.com/repositories/1234/forks', 307 } post('/repositories/1234/forks', :host_name => 'api.github.com') { status 202 json :name => 'my-dotfiles', :owner => { :login => 'MiSlAv' } } """ When I successfully run `hub fork` Then the output should contain exactly "new remote: mislav\n" And "git remote add -f mislav git://github.com/evilchelu/dotfiles.git" should be run And "git remote set-url mislav git@github.com:MiSlAv/my-dotfiles.git" should be run And the url for "mislav" should be "git@github.com:MiSlAv/my-dotfiles.git" Scenario: Fork the repository when origin URL is private Given the "origin" remote has url "git@github.com:evilchelu/dotfiles.git" And git protocol is preferred Given the GitHub API server: """ before { halt 401 unless request.env['HTTP_AUTHORIZATION'] == 'token OTOKEN' } get('/repos/mislav/dotfiles', :host_name => 'api.github.com') { 404 } post('/repos/evilchelu/dotfiles/forks', :host_name => 'api.github.com') { status 202 json :name => 'dotfiles', :owner => { :login => 'mislav' } } """ When I successfully run `hub fork` Then the output should contain exactly "new remote: mislav\n" And "git remote add -f mislav git://github.com/evilchelu/dotfiles.git" should be run And "git remote set-url mislav git@github.com:mislav/dotfiles.git" should be run And the url for "mislav" should be "git@github.com:mislav/dotfiles.git" Scenario: --no-remote Given the GitHub API server: """ post('/repos/evilchelu/dotfiles/forks') { status 202 json :name => 'dotfiles', :owner => { :login => 'mislav' } } """ When I successfully run `hub fork --no-remote` Then the output should not contain anything And there should be no "mislav" remote Scenario: Fork failed Given the GitHub API server: """ post('/repos/evilchelu/dotfiles/forks') { halt 500 } """ When I run `hub fork` Then the exit status should be 1 And the stderr should contain exactly: """ Error creating fork: Internal Server Error (HTTP 500)\n """ And there should be no "mislav" remote Scenario: Unrelated fork already exists Given the GitHub API server: """ get('/repos/mislav/dotfiles') { halt 406 unless request.env['HTTP_ACCEPT'] == 'application/vnd.github.v3+json;charset=utf-8' json :html_url => 'https://github.com/mislav/dotfiles', :parent => { :html_url => 'https://github.com/unrelated/dotfiles' } } """ When I run `hub fork` Then the exit status should be 1 And the stderr should contain exactly: """ Error creating fork: mislav/dotfiles already exists on github.com\n """ And there should be no "mislav" remote Scenario: Related fork already exists Given the GitHub API server: """ get('/repos/mislav/dotfiles') { json :html_url => 'https://github.com/mislav/dotfiles', :parent => { :html_url => 'https://github.com/EvilChelu/Dotfiles' } } """ When I run `hub fork` Then the exit status should be 0 Then the stdout should contain exactly: """ new remote: mislav\n """ And the url for "mislav" should be "https://github.com/mislav/dotfiles.git" Scenario: Redirected repo already exists Given the GitHub API server: """ get('/repos/mislav/dotfiles') { redirect 'https://api.github.com/repositories/12345', 301 } get('/repositories/12345') { json :html_url => 'https://github.com/mislav/old-dotfiles' } post('/repos/evilchelu/dotfiles/forks') { status 202 json :name => 'dotfiles', :owner => { :login => 'mislav' } } """ When I successfully run `hub fork` And the stdout should contain exactly "new remote: mislav\n" Scenario: Unrelated remote already exists Given the "mislav" remote has url "git@github.com:mislav/unrelated.git" Given the GitHub API server: """ get('/repos/mislav/dotfiles', :host_name => 'api.github.com') { 404 } post('/repos/evilchelu/dotfiles/forks', :host_name => 'api.github.com') { assert :organization => nil status 202 json :name => 'dotfiles', :owner => { :login => 'mislav' } } """ When I run `hub fork` Then the exit status should not be 0 And the stderr should contain: """ remote mislav already exists. """ And the url for "mislav" should be "git@github.com:mislav/unrelated.git" Scenario: Related fork and related remote already exist Given the "mislav" remote has url "git@github.com:mislav/dotfiles.git" Given the GitHub API server: """ get('/repos/mislav/dotfiles') { json :html_url => 'https://github.com/mislav/dotfiles', :parent => { :html_url => 'https://github.com/EvilChelu/Dotfiles' } } """ When I run `hub fork` Then the exit status should be 0 And the stdout should contain exactly: """ existing remote: mislav\n """ And the url for "mislav" should be "git@github.com:mislav/dotfiles.git" Scenario: Related fork and related remote, but with differing protocol, already exist Given the "mislav" remote has url "https://github.com/mislav/dotfiles.git" Given the GitHub API server: """ get('/repos/mislav/dotfiles') { json :html_url => 'https://github.com/mislav/dotfiles', :parent => { :html_url => 'https://github.com/EvilChelu/Dotfiles' } } """ When I run `hub fork` Then the exit status should be 0 And the stdout should contain exactly: """ existing remote: mislav\n """ And the url for "mislav" should be "https://github.com/mislav/dotfiles.git" Scenario: Invalid OAuth token Given the GitHub API server: """ before { halt 401 unless request.env['HTTP_AUTHORIZATION'] == 'token OTOKEN' } """ And I am "mislav" on github.com with OAuth token "WRONGTOKEN" When I run `hub fork` Then the exit status should be 1 And the stderr should contain exactly: """ Error creating fork: Unauthorized (HTTP 401)\n """ Scenario: Not in repo Given the current dir is not a repo When I run `hub fork` Then the exit status should be 1 And the stderr should contain "fatal: Not a git repository" Scenario: Origin remote doesn't exist Given I run `git remote rm origin` When I run `hub fork` Then the exit status should be 1 And the stderr should contain exactly: """ Aborted: could not find any git remote pointing to a GitHub repository\n """ And there should be no "origin" remote Scenario: Unknown host Given the "origin" remote has url "git@git.my.org:evilchelu/dotfiles.git" When I run `hub fork` Then the exit status should be 1 And the stderr should contain exactly: """ Aborted: could not find any git remote pointing to a GitHub repository\n """ Scenario: Enterprise fork Given the GitHub API server: """ before { halt 400 unless request.env['HTTP_X_ORIGINAL_SCHEME'] == 'https' halt 401 unless request.env['HTTP_AUTHORIZATION'] == 'token FITOKEN' } post('/api/v3/repos/evilchelu/dotfiles/forks', :host_name => 'git.my.org') { status 202 json :name => 'dotfiles', :owner => { :login => 'mislav' } } """ And the "origin" remote has url "git@git.my.org:evilchelu/dotfiles.git" And I am "mislav" on git.my.org with OAuth token "FITOKEN" And "git.my.org" is a whitelisted Enterprise host When I successfully run `hub fork` Then the url for "mislav" should be "https://git.my.org/mislav/dotfiles.git" Scenario: Enterprise fork using regular HTTP Given the GitHub API server: """ before { halt 400 unless request.env['HTTP_X_ORIGINAL_SCHEME'] == 'http' halt 400 unless request.env['HTTP_X_ORIGINAL_PORT'] == '80' halt 401 unless request.env['HTTP_AUTHORIZATION'] == 'token FITOKEN' } post('/api/v3/repos/evilchelu/dotfiles/forks', :host_name => 'git.my.org') { status 202 json :name => 'dotfiles', :owner => { :login => 'mislav' } } """ And the "origin" remote has url "git@git.my.org:evilchelu/dotfiles.git" And I am "mislav" on http://git.my.org with OAuth token "FITOKEN" And "git.my.org" is a whitelisted Enterprise host When I successfully run `hub fork` Then the url for "mislav" should be "https://git.my.org/mislav/dotfiles.git" Scenario: Fork a repo to a specific organization Given the GitHub API server: """ get('/repos/acme/dotfiles') { 404 } post('/repos/evilchelu/dotfiles/forks') { assert :organization => "acme" status 202 json :name => 'dotfiles', :owner => { :login => 'acme' } } """ When I successfully run `hub fork --org=acme` Then the output should contain exactly "new remote: acme\n" Then the url for "acme" should be "https://github.com/acme/dotfiles.git" ================================================ FILE: features/gist.feature ================================================ Feature: hub gist Background: Given I am "octokitten" on github.com with OAuth token "OTOKEN" Scenario: Fetch a gist with a single file Given the GitHub API server: """ get('/gists/myhash') { json({ :files => { 'hub_gist1.txt' => { 'content' => "my content is here", } }, :description => "my gist", }) } """ When I successfully run `hub gist show myhash` Then the output should contain exactly: """ my content is here\n """ Scenario: Fetch a gist with many files Given the GitHub API server: """ get('/gists/myhash') { json({ :files => { 'hub_gist1.txt' => { 'content' => "my content is here" }, 'hub_gist2.txt' => { 'content' => "more content is here" } }, :description => "my gist", :id => "myhash", }) } """ When I run `hub gist show myhash` Then the exit status should be 1 Then the stderr should contain: """ This gist contains multiple files, you must specify one: hub_gist1.txt hub_gist2.txt """ Scenario: Fetch a single file from gist Given the GitHub API server: """ get('/gists/myhash') { json({ :files => { 'hub_gist1.txt' => { 'content' => "my content is here" }, 'hub_gist2.txt' => { 'content' => "more content is here" } }, :description => "my gist", :id => "myhash", }) } """ When I successfully run `hub gist show myhash hub_gist1.txt` Then the output should contain exactly: """ my content is here\n """ Scenario: Create a gist from file Given the GitHub API server: """ post('/gists') { status 201 json :html_url => 'http://gists.github.com/somehash' } """ Given a file named "testfile.txt" with: """ this is a test file """ When I successfully run `hub gist create testfile.txt` Then the output should contain exactly: """ http://gists.github.com/somehash\n """ Scenario: Open the new gist in a browser Given the GitHub API server: """ post('/gists') { status 201 json :html_url => 'http://gists.github.com/somehash' } """ Given a file named "testfile.txt" with: """ this is a test file """ When I successfully run `hub gist create -o testfile.txt` Then the output should contain exactly "" And "open http://gists.github.com/somehash" should be run Scenario: Create a gist with multiple files Given the GitHub API server: """ post('/gists') { halt 400 unless params[:files]["testfile.txt"]["content"] halt 400 unless params[:files]["testfile2.txt"]["content"] status 201 json({ :html_url => 'http://gists.github.com/somehash', }) } """ Given a file named "testfile.txt" with: """ this is a test file """ Given a file named "testfile2.txt" with: """ this is another test file """ When I successfully run `hub gist create testfile.txt testfile2.txt` Then the output should contain exactly: """ http://gists.github.com/somehash\n """ Scenario: Create a gist from stdin Given the GitHub API server: """ post('/gists') { halt 400 unless params[:files]["gistfile1.txt"]["content"] == "hello\n" status 201 json :html_url => 'http://gists.github.com/somehash' } """ When I run `hub gist create` interactively And I pass in: """ hello """ Then the output should contain exactly: """ http://gists.github.com/somehash\n """ Scenario: Insufficient OAuth scopes Given the GitHub API server: """ post('/gists') { status 404 response.headers['x-accepted-oauth-scopes'] = 'gist' response.headers['x-oauth-scopes'] = 'repos' json({}) } """ Given a file named "testfile.txt" with: """ this is a test file """ When I run `hub gist create testfile.txt` Then the exit status should be 1 And the stderr should contain exactly: """ Error creating gist: Not Found (HTTP 404) Your access token may have insufficient scopes. Visit http://github.com/settings/tokens to edit the 'hub' token and enable one of the following scopes: gist\n """ Scenario: Infer correct OAuth scopes for gist Given the GitHub API server: """ post('/gists') { status 404 response.headers['x-oauth-scopes'] = 'repos' json({}) } """ Given a file named "testfile.txt" with: """ this is a test file """ When I run `hub gist create testfile.txt` Then the exit status should be 1 And the stderr should contain exactly: """ Error creating gist: Not Found (HTTP 404) Your access token may have insufficient scopes. Visit http://github.com/settings/tokens to edit the 'hub' token and enable one of the following scopes: gist\n """ Scenario: Create error Given the GitHub API server: """ post('/gists') { status 404 response.headers['x-accepted-oauth-scopes'] = 'gist' response.headers['x-oauth-scopes'] = 'repos, gist' json({}) } """ Given a file named "testfile.txt" with: """ this is a test file """ When I run `hub gist create testfile.txt` Then the exit status should be 1 And the stderr should contain exactly: """ Error creating gist: Not Found (HTTP 404)\n """ ================================================ FILE: features/git_compatibility.feature ================================================ Feature: git-hub compatibility Scenario: If alias named branch exists, it should not be expanded. Given I am in "git://github.com/rtomayko/ronn.git" git repo And the default branch for "origin" is "master" When I successfully run `git config --global alias.branch "branch -a"` When I run `hub branch` Then the stdout should contain exactly "* master\n" Scenario: List commands When I successfully run `hub --list-cmds=others` Then the stdout should contain exactly: """ add branch commit alias api browse ci-status compare create delete fork gist issue pr pull-request release sync\n """ Scenario: Doesn't sabotage --exec-path When I successfully run `hub --exec-path` Then the output should not contain "These GitHub commands" Scenario: Shows help with --git-dir When I run `hub --git-dir=.git` Then the exit status should be 1 And the output should contain "usage: git " ================================================ FILE: features/help.feature ================================================ Feature: hub help Scenario: Appends hub help to regular help text When I successfully run `hub help` Then the output should contain: """ These GitHub commands are provided by hub: api Low-level GitHub API request interface """ And the output should contain "usage: git " Scenario: Shows help text with no arguments When I run `hub` Then the stdout should contain "usage: git " And the stderr should contain exactly "" And the exit status should be 1 Scenario: Appends hub commands to `--all` output When I successfully run `hub help -a` Then the output should contain "pull-request" Scenario: Shows help for a hub extension When I successfully run `hub help hub-help` Then "man hub-help" should be run Scenario: Shows help for a hub command When I successfully run `hub help fork` Then "man hub-fork" should be run Scenario: Show help in HTML format When I successfully run `hub help -w fork` Then "man hub-fork" should not be run And "git web--browse PATH/hub-fork.1.html" should be run Scenario: Show help in HTML format by default Given I successfully run `git config --global help.format html` When I successfully run `hub help fork` Then "git web--browse PATH/hub-fork.1.html" should be run Scenario: Override HTML format back to man Given I successfully run `git config --global help.format html` When I successfully run `hub help -m fork` Then "man hub-fork" should be run Scenario: The --help flag opens man page When I successfully run `hub fork --help` Then "man hub-fork" should be run Scenario: The --help flag expands alias first Given I successfully run `git config --global alias.ci ci-status` When I successfully run `hub ci --help` Then "man hub-ci-status" should be run ================================================ FILE: features/init.feature ================================================ Feature: hub init Background: Given I am "mislav" on github.com with OAuth token "OTOKEN" Given a directory named "dotfiles" When I cd to "dotfiles" Scenario: Initializes a git repo with remote When I successfully run `hub init -g` Then the url for "origin" should be "https://github.com/mislav/dotfiles.git" Scenario: Initializes a git repo in a new directory with remote When I successfully run `hub init -g new_dir` And I cd to "new_dir" Then the url for "origin" should be "https://github.com/mislav/new_dir.git" Scenario: Enterprise host Given $GITHUB_HOST is "git.my.org" And I am "mislav" on git.my.org with OAuth token "FITOKEN" And "git.my.org" is a whitelisted Enterprise host When I successfully run `hub init -g` Then the url for "origin" should be "https://git.my.org/mislav/dotfiles.git" ================================================ FILE: features/issue-transfer.feature ================================================ Feature: hub issue transfer Background: Given I am in "git://github.com/octocat/hello-world.git" git repo And I am "srafi1" on github.com with OAuth token "OTOKEN" Scenario: Transfer issue Given the GitHub API server: """ count = 0 post('/graphql') { halt 401 unless request.env['HTTP_AUTHORIZATION'] == 'token OTOKEN' count += 1 case count when 1 assert :query => /\A\s*query\(/, :variables => { :issue => 123, :sourceOwner => "octocat", :sourceRepo => "hello-world", :targetOwner => "octocat", :targetRepo => "spoon-knife", } json :data => { :source => { :issue => { :id => "ISSUE-ID" } }, :target => { :id => "REPO-ID" }, } when 2 assert :query => /\A\s*mutation\(/, :variables => { :issue => "ISSUE-ID", :repo => "REPO-ID", } json :data => { :transferIssue => { :issue => { :url => "the://url" } } } else status 400 json :message => "request not stubbed" end } """ When I successfully run `hub issue transfer 123 spoon-knife` Then the output should contain exactly "the://url\n" Scenario: Transfer to another owner Given the GitHub API server: """ count = 0 post('/graphql') { count += 1 case count when 1 assert :variables => { :targetOwner => "monalisa", :targetRepo => "playground", } json :data => {} when 2 json :errors => [ { :message => "New repository must have the same owner as the current repository" }, ] else status 400 json :message => "request not stubbed" end } """ When I run `hub issue transfer 123 monalisa/playground` Then the exit status should be 1 Then the stderr should contain exactly: """ API error: New repository must have the same owner as the current repository\n """ ================================================ FILE: features/issue.feature ================================================ Feature: hub issue Background: Given I am in "git://github.com/github/hub.git" git repo And I am "cornwe19" on github.com with OAuth token "OTOKEN" Scenario: Fetch issues Given the GitHub API server: """ get('/repos/github/hub/issues') { assert :assignee => "Cornwe19", :sort => nil, :direction => "desc" json [ { :number => 999, :title => "First pull", :state => "open", :user => { :login => "octocat" }, :pull_request => { }, }, { :number => 102, :title => "First issue", :state => "open", :user => { :login => "octocat" }, }, { :number => 13, :title => "Second issue", :state => "open", :user => { :login => "octocat" }, }, ] } """ When I successfully run `hub issue -a Cornwe19` Then the output should contain exactly: """ #102 First issue #13 Second issue\n """ Scenario: List limited number of issues Given the GitHub API server: """ get('/repos/github/hub/issues') { response.headers["Link"] = %(; rel="next") assert :per_page => "3" json [ { :number => 102, :title => "First issue", :state => "open", :user => { :login => "octocat" }, }, { :number => 13, :title => "Second issue", :state => "open", :user => { :login => "octocat" }, }, { :number => 999, :title => "Third issue", :state => "open", :user => { :login => "octocat" }, }, ] } """ When I successfully run `hub issue -L 2` Then the output should contain exactly: """ #102 First issue #13 Second issue\n """ Scenario: Fetch issues and pull requests Given the GitHub API server: """ get('/repos/github/hub/issues') { assert :assignee => "Cornwe19", :sort => nil, :direction => "desc" json [ { :number => 999, :title => "First pull", :state => "open", :user => { :login => "octocat" }, :pull_request => { }, }, { :number => 102, :title => "First issue", :state => "open", :user => { :login => "octocat" }, }, { :number => 13, :title => "Second issue", :state => "open", :user => { :login => "octocat" }, }, ] } """ When I successfully run `hub issue -a Cornwe19 --include-pulls` Then the output should contain exactly: """ #999 First pull #102 First issue #13 Second issue\n """ Scenario: Fetch issues not assigned to any milestone Given the GitHub API server: """ get('/repos/github/hub/issues') { assert :milestone => "none" json [] } """ When I successfully run `hub issue -M none` Scenario: Fetch issues assigned to milestone by number Given the GitHub API server: """ get('/repos/github/hub/issues') { assert :milestone => "12" json [] } """ When I successfully run `hub issue -M 12` Scenario: Fetch issues assigned to milestone by name Given the GitHub API server: """ get('/repos/github/hub/milestones') { status 200 json [ { :number => 237, :title => "prerelease" }, { :number => 1337, :title => "v1" }, { :number => 41319, :title => "Hello World!" } ] } get('/repos/github/hub/issues') { assert :milestone => "1337" json [] } """ When I successfully run `hub issue -M v1` Scenario: Fetch issues created by a given user Given the GitHub API server: """ get('/repos/github/hub/issues') { assert :creator => "octocat" json [] } """ When I successfully run `hub issue -c octocat` Scenario: Fetch issues mentioning a given user Given the GitHub API server: """ get('/repos/github/hub/issues') { assert :mentioned => "octocat" json [] } """ When I successfully run `hub issue -@ octocat` Scenario: Fetch issues with certain labels Given the GitHub API server: """ get('/repos/github/hub/issues') { assert :labels => "foo,bar" json [] } """ When I successfully run `hub issue -l foo,bar` Scenario: Fetch issues updated after a certain date and time Given the GitHub API server: """ get('/repos/github/hub/issues') { assert :since => "2016-08-18T09:11:32Z" json [] } """ When I successfully run `hub issue -d 2016-08-18T09:11:32Z` Scenario: Fetch issues sorted by number of comments ascending Given the GitHub API server: """ get('/repos/github/hub/issues') { assert :sort => "comments" assert :direction => "asc" json [] } """ When I successfully run `hub issue -o comments -^` Scenario: Fetch issues across multiple pages Given the GitHub API server: """ get('/repos/github/hub/issues') { assert :per_page => "100", :page => :no response.headers["Link"] = %(; rel="next") json [ { :number => 102, :title => "First issue", :state => "open", :user => { :login => "octocat" }, }, ] } get('/repositories/12345') { assert :per_page => "100" if params[:page] == "2" response.headers["Link"] = %(; rel="next") json [ { :number => 13, :title => "Second issue", :state => "open", :user => { :login => "octocat" }, }, { :number => 103, :title => "Issue from 2nd page", :state => "open", :user => { :login => "octocat" }, }, ] elsif params[:page] == "3" json [ { :number => 21, :title => "Even more issuez", :state => "open", :user => { :login => "octocat" }, }, ] else status 400 end } """ When I successfully run `hub issue` Then the output should contain exactly: """ #102 First issue #13 Second issue #103 Issue from 2nd page #21 Even more issuez\n """ Scenario: Custom format for issues list Given the GitHub API server: """ get('/repos/github/hub/issues') { assert :assignee => 'Cornwe19' json [ { :number => 102, :title => "First issue", :state => "open", :user => { :login => "lascap" }, }, { :number => 13, :title => "Second issue", :state => "closed", :user => { :login => "mislav" }, }, ] } """ When I successfully run `hub issue -f "%I,%au%n" -a Cornwe19` Then the output should contain exactly: """ 102,lascap 13,mislav\n """ Scenario: Custom format with no-color labels Given the GitHub API server: """ get('/repos/github/hub/issues') { json [ { :number => 102, :title => "First issue", :state => "open", :user => { :login => "morganwahl" }, :labels => [ { :name => 'Has Migration', :color => 'cfcfcf' }, { :name => 'Maintenance Window', :color => '888888' }, ] }, { :number => 201, :title => "No labels", :state => "open", :user => { :login => "octocat" }, :labels => [] }, ] } """ When I successfully run `hub issue -f "%I: %L%n" --color=never` Then the output should contain exactly: """ 102: Has Migration, Maintenance Window 201: \n """ Scenario: List all assignees Given the GitHub API server: """ get('/repos/github/hub/issues') { json [ { :number => 102, :title => "First issue", :state => "open", :user => { :login => "octocat" }, :assignees => [ { :login => "mislav" }, { :login => "lascap" }, ] }, { :number => 13, :title => "Second issue", :state => "closed", :user => { :login => "octocat" }, :assignees => [ { :login => "keenahn" }, ] }, ] } """ When I successfully run `hub issue -f "%I:%as%n"` Then the output should contain exactly: """ 102:mislav, lascap 13:keenahn\n """ Scenario: Create an issue Given the GitHub API server: """ post('/repos/github/hub/issues') { assert :title => "Not workie, pls fix", :body => "", :labels => :no status 201 json :html_url => "https://github.com/github/hub/issues/1337" } """ When I successfully run `hub issue create -m "Not workie, pls fix"` Then the output should contain exactly: """ https://github.com/github/hub/issues/1337\n """ Scenario: Create an issue and open in browser Given the GitHub API server: """ post('/repos/github/hub/issues') { status 201 json :html_url => "the://url" } """ When I successfully run `hub issue create -o -m hello` Then the output should contain exactly "" Then "open the://url" should be run Scenario: Create an issue with labels Given the GitHub API server: """ post('/repos/github/hub/issues') { assert :title => "hello", :body => "", :milestone => :no, :assignees => :no, :labels => ["wont fix", "docs", "nope"] status 201 json :html_url => "https://github.com/github/hub/issues/1337" } """ When I successfully run `hub issue create -m "hello" -l "wont fix,docs" -lnope` Then the output should contain exactly: """ https://github.com/github/hub/issues/1337\n """ Scenario: Create an issue with milestone and assignees Given the GitHub API server: """ post('/repos/github/hub/issues') { assert :title => "hello", :body => "", :milestone => 12, :assignees => ["mislav", "josh", "pcorpet"], :labels => :no status 201 json :html_url => "https://github.com/github/hub/issues/1337" } """ When I successfully run `hub issue create -m "hello" -M 12 --assign mislav,josh -apcorpet` Then the output should contain exactly: """ https://github.com/github/hub/issues/1337\n """ Scenario: Create an issue with milestone by name Given the GitHub API server: """ get('/repos/github/hub/milestones') { status 200 json [ { :number => 237, :title => "prerelease" }, { :number => 1337, :title => "v1" }, { :number => 41319, :title => "Hello World!" } ] } post('/repos/github/hub/issues') { assert :milestone => 41319 status 201 json :html_url => "https://github.com/github/hub/issues/1337" } """ When I successfully run `hub issue create -m "hello" -M "hello world!"` Then the output should contain exactly: """ https://github.com/github/hub/issues/1337\n """ Scenario: Editing empty issue message Given the git commit editor is "vim" And the text editor adds: """ hello my nice issue """ Given the GitHub API server: """ post('/repos/github/hub/issues') { assert :title => "hello", :body => "my nice issue" status 201 json :html_url => "https://github.com/github/hub/issues/1337" } """ When I successfully run `hub issue create -m '' --edit` Then the output should contain exactly: """ https://github.com/github/hub/issues/1337\n """ Scenario: Issue template Given the git commit editor is "vim" And the text editor adds: """ hello """ And a file named "issue_template.md" with: """ my nice issue template """ Given the GitHub API server: """ post('/repos/github/hub/issues') { assert :title => "hello", :body => "my nice issue template" status 201 json :html_url => "https://github.com/github/hub/issues/1337" } """ When I successfully run `hub issue create` Then the output should contain exactly: """ https://github.com/github/hub/issues/1337\n """ Scenario: Issue template from a subdirectory Given the git commit editor is "vim" And the text editor adds: """ hello """ And a file named ".github/issue_template.md" with: """ my nice issue template """ Given the GitHub API server: """ post('/repos/github/hub/issues') { assert :title => "hello", :body => "my nice issue template" status 201 json :html_url => "https://github.com/github/hub/issues/1337" } """ Given a directory named "subdir" When I cd to "subdir" And I successfully run `hub issue create` Then the output should contain exactly: """ https://github.com/github/hub/issues/1337\n """ Scenario: Multiple issue templates Given the git commit editor is "vim" And the text editor adds: """ hello """ And a file named ".github/ISSUE_TEMPLATE/bug_report.md" with: """ I want to report a bug """ And a file named ".github/ISSUE_TEMPLATE/feature_request.md" with: """ There is a feature that I need! """ Given the GitHub API server: """ post('/repos/github/hub/issues') { assert :title => "hello", :body => "" status 201 json :html_url => "https://github.com/github/hub/issues/1337" } """ When I successfully run `hub issue create` Then the output should contain exactly: """ https://github.com/github/hub/issues/1337\n """ Scenario: Multiple issue templates with default Given the git commit editor is "vim" And the text editor adds: """ hello """ And a directory named ".github/ISSUE_TEMPLATE" And a file named ".github/ISSUE_TEMPLATE.md" with: """ The default template """ Given the GitHub API server: """ post('/repos/github/hub/issues') { assert :title => "hello", :body => "The default template" status 201 json :html_url => "https://github.com/github/hub/issues/1337" } """ When I successfully run `hub issue create` Then the output should contain exactly: """ https://github.com/github/hub/issues/1337\n """ Scenario: A file named ".github" Given the git commit editor is "vim" And the text editor adds: """ hello """ And a file named ".github" with: """ this is ignored """ Given the GitHub API server: """ post('/repos/github/hub/issues') { assert :title => "hello", :body => "" status 201 json :html_url => "https://github.com/github/hub/issues/1337" } """ When I successfully run `hub issue create` Then the output should contain exactly: """ https://github.com/github/hub/issues/1337\n """ Scenario: Update an issue's title Given the GitHub API server: """ patch('/repos/github/hub/issues/1337') { assert :title => "Not workie, pls fix", :body => "", :milestone => :no, :assignees => :no, :labels => :no, :state => :no } """ Then I successfully run `hub issue update 1337 -m "Not workie, pls fix"` Scenario: Update an issue's state Given the GitHub API server: """ patch('/repos/github/hub/issues/1337') { assert :title => :no, :labels => :no, :state => "closed" } """ Then I successfully run `hub issue update 1337 -s closed` Scenario: Update an issue's labels Given the GitHub API server: """ patch('/repos/github/hub/issues/1337') { assert :title => :no, :body => :no, :milestone => :no, :assignees => :no, :labels => ["bug", "important"] } """ Then I successfully run `hub issue update 1337 -l bug,important` Scenario: Update an issue's milestone Given the GitHub API server: """ patch('/repos/github/hub/issues/1337') { assert :title => :no, :body => :no, :milestone => 42, :assignees => :no, :labels => :no } """ Then I successfully run `hub issue update 1337 -M 42` Scenario: Update an issue's milestone by name Given the GitHub API server: """ get('/repos/github/hub/milestones') { status 200 json [ { :number => 237, :title => "prerelease" }, { :number => 42, :title => "Hello World!" } ] } patch('/repos/github/hub/issues/1337') { assert :title => :no, :body => :no, :milestone => 42, :assignees => :no, :labels => :no } """ Then I successfully run `hub issue update 1337 -M "hello world!"` Scenario: Update an issue's assignees Given the GitHub API server: """ patch('/repos/github/hub/issues/1337') { assert :title => :no, :body => :no, :milestone => :no, :assignees => ["Cornwe19"], :labels => :no } """ Then I successfully run `hub issue update 1337 -a Cornwe19` Scenario: Update an issue's title, labels, milestone, and assignees Given the GitHub API server: """ patch('/repos/github/hub/issues/1337') { assert :title => "Not workie, pls fix", :body => "", :milestone => 42, :assignees => ["Cornwe19"], :labels => ["bug", "important"] } """ Then I successfully run `hub issue update 1337 -m "Not workie, pls fix" -M 42 -l bug,important -a Cornwe19` Scenario: Clear existing issue labels, assignees, milestone Given the GitHub API server: """ patch('/repos/github/hub/issues/1337') { assert :title => :no, :body => :no, :milestone => nil, :assignees => [], :labels => [] } """ Then I successfully run `hub issue update 1337 --milestone= --assign= --labels=` Scenario: Update an issue's title and body manually Given the git commit editor is "vim" And the text editor adds: """ My new title """ Given the GitHub API server: """ get('/repos/github/hub/issues/1337') { json \ :number => 1337, :title => "My old title", :body => "My old body" } patch('/repos/github/hub/issues/1337') { assert :title => "My new title", :body => "My old title\n\nMy old body", :milestone => :no, :assignees => :no, :labels => :no } """ Then I successfully run `hub issue update 1337 --edit` Scenario: Update an issue's title and body via a file Given a file named "my-issue.md" with: """ My new title My new body """ Given the GitHub API server: """ patch('/repos/github/hub/issues/1337') { assert :title => "My new title", :body => "My new body", :milestone => :no, :assignees => :no, :labels => :no } """ Then I successfully run `hub issue update 1337 -F my-issue.md` Scenario: Update an issue without specifying fields to update When I run `hub issue update 1337` Then the exit status should be 1 Then the stderr should contain "please specify fields to update" Then the stderr should contain "Usage: hub issue" Scenario: Fetch issue labels Given the GitHub API server: """ get('/repos/github/hub/labels') { response.headers["Link"] = %(; rel="next") assert :per_page => "100", :page => nil json [ { :name => "Discuss", :color => "0000ff", }, { :name => "bug", :color => "ff0000", }, { :name => "feature", :color => "00ff00", }, ] } get('/repositories/12345/labels') { assert :per_page => "100", :page => "2" json [ { :name => "affects", :color => "ffffff", }, ] } """ When I successfully run `hub issue labels` Then the output should contain exactly: """ affects bug Discuss feature\n """ Scenario: Fetch single issue Given the GitHub API server: """ get('/repos/github/hub/issues/102') { json \ :number => 102, :state => "open", :body => "I want this feature", :title => "Feature request for hub issue show", :created_at => "2017-04-14T16:00:49Z", :user => { :login => "royels" }, :assignees => [{:login => "royels"}], :comments => 1 } get('/repos/github/hub/issues/102/comments') { json [ { :body => "I am from the future", :created_at => "2011-04-14T16:00:49Z", :user => { :login => "octocat" } }, { :body => "I did the thing", :created_at => "2013-10-30T22:20:00Z", :user => { :login => "hubot" } }, ] } """ When I successfully run `hub issue show 102` Then the output should contain exactly: """ # Feature request for hub issue show * created by @royels on 2017-04-14 16:00:49 +0000 UTC * assignees: royels I want this feature ## Comments: ### comment by @octocat on 2011-04-14 16:00:49 +0000 UTC I am from the future ### comment by @hubot on 2013-10-30 22:20:00 +0000 UTC I did the thing\n """ Scenario: Format single issue Given the GitHub API server: """ get('/repos/github/hub/issues/102') { json \ :number => 102, :state => "open", :body => "I want this feature", :title => "Feature request for hub issue show", :created_at => "2017-04-14T16:00:49Z", :user => { :login => "royels" }, :assignees => [{:login => "royels"}], :comments => 1 } get('/repos/github/hub/issues/102/comments') { json [ { :body => "I am from the future", :created_at => "2011-04-14T16:00:49Z", :user => { :login => "octocat" } }, { :body => "I did the thing", :created_at => "2013-10-30T22:20:00Z", :user => { :login => "hubot" } }, ] } """ When I successfully run `hub issue show 102 --format='%I %t%n%n%b%n'` Then the output should contain exactly: """ 102 Feature request for hub issue show I want this feature\n """ Scenario: Format with literal % characters Given the GitHub API server: """ get('/repos/github/hub/issues/102') { json \ :number => 102, :state => "open", :title => "Feature request % hub", :user => { :login => "alexfornuto" } } get('/repos/github/hub/issues/102/comments') { json [] } """ When I successfully run `hub issue show 102 --format='%t%%t%%n%n'` Then the output should contain exactly: """ Feature request % hub%t%n\n """ Scenario: Did not supply an issue number When I run `hub issue show` Then the exit status should be 1 Then the stderr should contain "Usage: hub issue" Scenario: Show error message if http code is not 200 for issues endpoint Given the GitHub API server: """ get('/repos/github/hub/issues/102') { status 500 } """ When I run `hub issue show 102` Then the output should contain exactly: """ Error fetching issue: Internal Server Error (HTTP 500)\n """ Scenario: Show error message if http code is not 200 for comments endpoint Given the GitHub API server: """ get('/repos/github/hub/issues/102') { json \ :number => 102, :body => "I want this feature", :title => "Feature request for hub issue show", :created_at => "2017-04-14T16:00:49Z", :user => { :login => "royels" } } get('/repos/github/hub/issues/102/comments') { status 404 } """ When I run `hub issue show 102` Then the output should contain exactly: """ Error fetching comments for issue: Not Found (HTTP 404)\n """ ================================================ FILE: features/merge.feature ================================================ Feature: hub merge Background: Given I am in "hub" git repo And the "origin" remote has url "git://github.com/defunkt/hub.git" And I am "mislav" on github.com with OAuth token "OTOKEN" Scenario: Normal merge When I run `hub merge master` Then the git command should be unchanged Scenario: Merge pull request Given the GitHub API server: """ get('/repos/defunkt/hub/pulls/164') { json \ :base => { :repo => { :owner => { :login => "defunkt" }, :name => "hub", :private => false } }, :head => { :ref => "hub_merge", :repo => { :owner => { :login => "jfirebaugh" }, :name => "hub", :private => false } }, :title => "Add `hub merge` command" } """ And there is a git FETCH_HEAD When I successfully run `hub merge https://github.com/defunkt/hub/pull/164` Then "git fetch origin refs/pull/164/head" should be run And "git merge FETCH_HEAD --no-ff -m Merge pull request #164 from jfirebaugh/hub_merge" should be run When I successfully run `git show -s --format=%B` Then the output should contain: """ Merge pull request #164 from jfirebaugh/hub_merge Add `hub merge` command """ Scenario: Merge pull request with options Given the GitHub API server: """ require 'json' get('/repos/defunkt/hub/pulls/164') { json \ :base => { :repo => { :owner => { :login => "defunkt" }, :name => "hub", :private => false } }, :head => { :ref => "hub_merge", :repo => { :owner => { :login => "jfirebaugh" }, :name => "hub", :private => false } }, :title => "Add `hub merge` command" } """ And there is a git FETCH_HEAD When I successfully run `hub merge --squash https://github.com/defunkt/hub/pull/164 --no-edit` Then "git fetch origin refs/pull/164/head" should be run And "git merge --squash --no-edit FETCH_HEAD -m Merge pull request #164 from jfirebaugh/hub_merge" should be run Scenario: Merge pull request no repo Given the GitHub API server: """ require 'json' get('/repos/defunkt/hub/pulls/164') { json \ :base => { :repo => { :owner => { :login => "defunkt" }, :name => "hub", :private => false } }, :head => { :ref => "hub_merge", :repo => nil }, :title => "Add `hub merge` command" } """ When I run `hub merge https://github.com/defunkt/hub/pull/164` Then the exit status should be 1 And the stderr should contain exactly: """ Error: that fork is not available anymore\n """ ================================================ FILE: features/pr-checkout.feature ================================================ Feature: hub pr checkout Background: Given I am in "git://github.com/mojombo/jekyll.git" git repo And I am "mojombo" on github.com with OAuth token "OTOKEN" Scenario: Checkout a pull request Given the GitHub API server: """ get('/repos/mojombo/jekyll/pulls/77') { json :number => 77, :head => { :ref => "fixes", :repo => { :owner => { :login => "mislav" }, :name => "jekyll", :private => false } }, :base => { :repo => { :name => 'jekyll', :html_url => 'https://github.com/mojombo/jekyll', :owner => { :login => "mojombo" }, } }, :maintainer_can_modify => false, :html_url => 'https://github.com/mojombo/jekyll/pull/77' } """ When I successfully run `hub pr checkout 77` Then "git fetch origin refs/pull/77/head:fixes" should be run And "git checkout fixes" should be run And "fixes" should merge "refs/pull/77/head" from remote "origin" Scenario: Custom name for new branch Given the GitHub API server: """ get('/repos/mojombo/jekyll/pulls/77') { json :number => 77, :head => { :ref => "fixes", :repo => { :name => "jekyll", :owner => { :login => "mislav" }, } }, :base => { :repo => { :name => 'jekyll', :html_url => 'https://github.com/mojombo/jekyll', :owner => { :login => "mojombo" }, } }, :maintainer_can_modify => false, :html_url => 'https://github.com/mojombo/jekyll/pull/77' } """ When I successfully run `hub pr checkout 77 fixes-from-mislav` Then "git fetch origin refs/pull/77/head:fixes-from-mislav" should be run And "git checkout fixes-from-mislav" should be run And "fixes-from-mislav" should merge "refs/pull/77/head" from remote "origin" Scenario: Same-repo Given the GitHub API server: """ get('/repos/mojombo/jekyll/pulls/77') { json :number => 77, :head => { :ref => "fixes", :repo => { :name => "jekyll", :owner => { :login => "mojombo" }, } }, :base => { :repo => { :name => "jekyll", :html_url => "https://github.com/mojombo/jekyll", :owner => { :login => "mojombo" }, } }, :html_url => 'https://github.com/mojombo/jekyll/pull/77' } """ When I successfully run `hub pr checkout 77` Then "git fetch origin +refs/heads/fixes:refs/remotes/origin/fixes" should be run And "git checkout -b fixes --no-track origin/fixes" should be run And "fixes" should merge "refs/heads/fixes" from remote "origin" ================================================ FILE: features/pr-list.feature ================================================ Feature: hub pr list Background: Given I am in "git://github.com/github/hub.git" git repo And I am "defunkt" on github.com with OAuth token "OTOKEN" Scenario: List pulls Given the GitHub API server: """ get('/repos/github/hub/pulls') { assert :per_page => "100", :page => :no, :sort => nil, :direction => "desc" response.headers["Link"] = %(; rel="next") json [ { :number => 999, :title => "First", :state => "open", :base => { :ref => "master", :label => "github:master" }, :head => { :ref => "patch-1", :label => "octocat:patch-1" }, :user => { :login => "octocat" }, }, { :number => 102, :title => "Second", :state => "open", :base => { :ref => "master", :label => "github:master" }, :head => { :ref => "patch-2", :label => "octocat:patch-2" }, :user => { :login => "octocat" }, }, { :number => 13, :title => "Third", :state => "open", :base => { :ref => "master", :label => "github:master" }, :head => { :ref => "patch-3", :label => "octocat:patch-3" }, :user => { :login => "octocat" }, }, ] } get('/repositories/12345') { assert :per_page => "100", :page => "2" json [ { :number => 7, :title => "Fourth", :state => "open", :base => { :ref => "master", :label => "github:master" }, :head => { :ref => "patch-4", :label => "octocat:patch-4" }, :user => { :login => "octocat" }, }, ] } """ When I successfully run `hub pr list` Then the output should contain exactly: """ #999 First #102 Second #13 Third #7 Fourth\n """ Scenario: List pull requests with requested reviewers Given the GitHub API server: """ get('/repos/github/hub/pulls') { assert :per_page => "100", :page => :no, :sort => nil, :direction => "desc" json [ { :number => 999, :title => "First", :state => "open", :base => { :ref => "master", :label => "github:master", :repo => { :owner => { :login => "github" } } }, :head => { :ref => "patch-1", :label => "octocat:patch-1" }, :user => { :login => "octocat" }, :requested_reviewers => [ { :login => "rey" }, ], :requested_teams => [ { :slug => "troopers" }, { :slug => "cantina-band" }, ] }, { :number => 102, :title => "Second", :state => "open", :base => { :ref => "master", :label => "github:master" }, :head => { :ref => "patch-2", :label => "octocat:patch-2" }, :user => { :login => "octocat" }, :requested_reviewers => [ { :login => "luke" }, { :login => "jyn" }, ] }, ] } """ When I successfully run `hub pr list -f "%sC%>(8)%i %rs%n"` Then the output should contain exactly: """ #999 rey, github/troopers, github/cantina-band #102 luke, jyn\n """ @keep-ansi-escape-sequences Scenario: List draft status Given the GitHub API server: """ get('/repos/github/hub/pulls') { halt 400 unless env['HTTP_ACCEPT'] == 'application/vnd.github.shadow-cat-preview+json;charset=utf-8' json [ { :number => 999, :state => "open", :draft => true, :merged_at => nil, :base => { :ref => "master", :label => "github:master" }, :head => { :ref => "patch-2", :label => "octocat:patch-2" }, :user => { :login => "octocat" }, }, { :number => 102, :state => "open", :draft => false, :merged_at => nil, :base => { :ref => "master", :label => "github:master" }, :head => { :ref => "patch-1", :label => "octocat:patch-1" }, :user => { :login => "octocat" }, }, { :number => 42, :state => "closed", :draft => false, :merged_at => "2018-12-11T10:50:33Z", :base => { :ref => "master", :label => "github:master" }, :head => { :ref => "patch-3", :label => "octocat:patch-3" }, :user => { :login => "octocat" }, }, { :number => 8, :state => "closed", :draft => false, :merged_at => nil, :base => { :ref => "master", :label => "github:master" }, :head => { :ref => "patch-4", :label => "octocat:patch-4" }, :user => { :login => "octocat" }, }, ] } """ When I successfully run `hub pr list --format "%I %pC %pS %Creset%n" --color` Then its output should contain exactly: """ 999 \e[37m draft \e[m 102 \e[32m open \e[m 42 \e[35m merged \e[m 8 \e[31m closed \e[m\n """ When I successfully run `hub -c color.ui=always pr list --format "%I %pC %pS %Creset%n"` Then its output should contain exactly: """ 999 \e[37m draft \e[m 102 \e[32m open \e[m 42 \e[35m merged \e[m 8 \e[31m closed \e[m\n """ When I successfully run `hub -c color.ui=false pr list --format "%I %pC%pS%Creset%n" --color=auto` Then its output should contain exactly: """ 999 draft 102 open 42 merged 8 closed\n """ Scenario: Sort by number of comments ascending Given the GitHub API server: """ get('/repos/github/hub/pulls') { assert :sort => "comments", :direction => "asc" json [] } """ When I successfully run `hub pr list -o comments -^` Then the output should contain exactly "" Scenario: Filter by base and head Given the GitHub API server: """ get('/repos/github/hub/pulls') { assert :base => "develop", :head => "github:patch-1" json [] } """ When I successfully run `hub pr list -b develop -h patch-1` Then the output should contain exactly "" Scenario: Filter by head with owner Given the GitHub API server: """ get('/repos/github/hub/pulls') { assert :head => "mislav:patch-1" json [] } """ When I successfully run `hub pr list -h mislav:patch-1` Then the output should contain exactly "" Scenario: Filter by merged state Given the GitHub API server: """ get('/repos/github/hub/pulls') { assert :state => "closed" json [ { :number => 999, :title => "First", :state => "closed", :merged_at => "2018-12-11T10:50:33Z", :base => { :ref => "master", :label => "github:master" }, :head => { :ref => "patch-1", :label => "octocat:patch-1" }, :user => { :login => "octocat" }, }, { :number => 102, :title => "Second", :state => "closed", :merged_at => nil, :base => { :ref => "master", :label => "github:master" }, :head => { :ref => "patch-2", :label => "octocat:patch-2" }, :user => { :login => "octocat" }, }, { :number => 13, :title => "Third", :state => "closed", :merged_at => "2018-12-11T10:50:33Z", :base => { :ref => "master", :label => "github:master" }, :head => { :ref => "patch-3", :label => "octocat:patch-3" }, :user => { :login => "octocat" }, }, ] } """ When I successfully run `hub pr list --state=merged` Then the output should contain exactly: """ #999 First #13 Third\n """ ================================================ FILE: features/pr-merge.feature ================================================ Feature: hub pr merge Background: Given I am in "git://github.com/friederbluemle/hub.git" git repo And I am "friederbluemle" on github.com with OAuth token "OTOKEN" Scenario: Default merge Given the GitHub API server: """ put('/repos/friederbluemle/hub/pulls/12/merge'){ assert :merge_method => "merge", :commit_title => :no, :commit_message => :no, :sha => :no json :merged => true, :sha => "MERGESHA", :message => "All done!" } """ When I successfully run `hub pr merge 12` Then the output should contain exactly "" Scenario: Squash merge Given the GitHub API server: """ put('/repos/friederbluemle/hub/pulls/12/merge'){ assert :merge_method => "squash", :commit_title => :no, :commit_message => :no, :sha => :no json :merged => true, :sha => "MERGESHA", :message => "All done!" } """ When I successfully run `hub pr merge --squash 12` Then the output should contain exactly "" Scenario: Merge with rebase Given the GitHub API server: """ put('/repos/friederbluemle/hub/pulls/12/merge'){ assert :merge_method => "rebase", :commit_title => :no, :commit_message => :no, :sha => :no json :merged => true, :sha => "MERGESHA", :message => "All done!" } """ When I successfully run `hub pr merge --rebase 12` Then the output should contain exactly "" Scenario: Merge with title Given the GitHub API server: """ put('/repos/friederbluemle/hub/pulls/12/merge'){ assert :commit_title => "mytitle", :commit_message => "" json :merged => true, :sha => "MERGESHA", :message => "All done!" } """ When I successfully run `hub pr merge 12 -m mytitle` Then the output should contain exactly "" Scenario: Merge with title and body Given the GitHub API server: """ put('/repos/friederbluemle/hub/pulls/12/merge'){ assert :commit_title => "mytitle", :commit_message => "msg1\n\nmsg2" json :merged => true, :sha => "MERGESHA", :message => "All done!" } """ When I successfully run `hub pr merge 12 -m mytitle -m msg1 -m msg2` Then the output should contain exactly "" Scenario: Merge with title and body from file Given a file named "msg.txt" with: """ mytitle msg1 msg2 """ Given the GitHub API server: """ put('/repos/friederbluemle/hub/pulls/12/merge'){ assert :commit_title => "mytitle", :commit_message => "msg1\n\nmsg2" json :merged => true, :sha => "MERGESHA", :message => "All done!" } """ When I successfully run `hub pr merge 12 -F msg.txt` Then the output should contain exactly "" Scenario: Merge with head SHA Given the GitHub API server: """ put('/repos/friederbluemle/hub/pulls/12/merge'){ assert :sha => "MYSHA" json :merged => true, :sha => "MERGESHA", :message => "All done!" } """ When I successfully run `hub pr merge 12 --head-sha MYSHA` Then the output should contain exactly "" Scenario: Delete branch Given the GitHub API server: """ put('/repos/friederbluemle/hub/pulls/12/merge'){ json :merged => true, :sha => "MERGESHA", :message => "All done!" } get('/repos/friederbluemle/hub/pulls/12'){ json \ :number => 12, :state => "merged", :base => { :ref => "main", :label => "friederbluemle:main", :repo => { :owner => { :login => "friederbluemle" } } }, :head => { :ref => "patch-1", :label => "friederbluemle:patch-1", :repo => { :owner => { :login => "friederbluemle" } } } } delete('/repos/friederbluemle/hub/git/refs/heads/patch-1'){ status 204 } """ When I successfully run `hub pr merge -d 12` Then the output should contain exactly "" Scenario: Delete already deleted branch Given the GitHub API server: """ put('/repos/friederbluemle/hub/pulls/12/merge'){ json :merged => true, :sha => "MERGESHA", :message => "All done!" } get('/repos/friederbluemle/hub/pulls/12'){ json \ :number => 12, :state => "merged", :base => { :ref => "main", :label => "friederbluemle:main", :repo => { :owner => { :login => "friederbluemle" } } }, :head => { :ref => "patch-1", :label => "friederbluemle:patch-1", :repo => { :owner => { :login => "friederbluemle" } } } } delete('/repos/friederbluemle/hub/git/refs/heads/patch-1'){ status 422 json :message => "Invalid branch name" } """ When I successfully run `hub pr merge -d 12` Then the output should contain exactly "" Scenario: Delete branch on cross-repo PR Given the GitHub API server: """ put('/repos/friederbluemle/hub/pulls/12/merge'){ json :merged => true, :sha => "MERGESHA", :message => "All done!" } get('/repos/friederbluemle/hub/pulls/12'){ json \ :number => 12, :state => "merged", :base => { :ref => "main", :label => "friederbluemle:main", :repo => { :owner => { :login => "friederbluemle" } } }, :head => { :ref => "patch-1", :label => "monalisa:patch-1", :repo => { :owner => { :login => "monalisa" } } } } """ When I successfully run `hub pr merge -d 12` Then the output should contain exactly "" ================================================ FILE: features/pr-show.feature ================================================ Feature: hub pr show Background: Given I am in "git://github.com/ashemesh/hub.git" git repo And I am "ashemesh" on github.com with OAuth token "OTOKEN" Scenario: Current branch Given I am on the "topic" branch Given the GitHub API server: """ get('/repos/ashemesh/hub/pulls'){ assert :state => "open", :head => "ashemesh:topic" json [ { :html_url => "https://github.com/ashemesh/hub/pull/102" }, ] } """ When I successfully run `hub pr show` Then "open https://github.com/ashemesh/hub/pull/102" should be run Scenario: Current branch output URL Given I am on the "topic" branch Given the GitHub API server: """ get('/repos/ashemesh/hub/pulls'){ assert :state => "open", :head => "ashemesh:topic" json [ { :html_url => "https://github.com/ashemesh/hub/pull/102" }, ] } """ When I successfully run `hub pr show -u` Then "open https://github.com/ashemesh/hub/pull/102" should not be run And the output should contain exactly: """ https://github.com/ashemesh/hub/pull/102\n """ Scenario: Format Current branch output URL Given I am on the "topic" branch Given the GitHub API server: """ get('/repos/ashemesh/hub/pulls'){ assert :state => "open", :head => "ashemesh:topic" json [{ :number => 102, :state => "open", :base => { :ref => "master", :label => "github:master", :repo => { :owner => { :login => "github" } } }, :head => { :ref => "patch-1", :label => "octocat:patch-1" }, :user => { :login => "octocat" }, :requested_reviewers => [ { :login => "rey" }, ], :requested_teams => [ { :slug => "troopers" }, { :slug => "cantina-band" }, ], :html_url => "https://github.com/ashemesh/hub/pull/102", }] } """ When I successfully run `hub pr show -f "%sC%>(8)%i %rs%n"` Then "open https://github.com/ashemesh/hub/pull/102" should not be run And the output should contain exactly: """ #102 rey, github/troopers, github/cantina-band\n\n """ Scenario: Current branch in fork Given the "upstream" remote has url "git@github.com:github/hub.git" And I am on the "topic" branch pushed to "origin/topic" Given the GitHub API server: """ get('/repos/github/hub/pulls'){ assert :state => "open", :head => "ashemesh:topic" json [ { :html_url => "https://github.com/github/hub/pull/102" }, ] } """ When I successfully run `hub pr show` Then "open https://github.com/github/hub/pull/102" should be run Scenario: Differently named branch in fork Given the "upstream" remote has url "git@github.com:github/hub.git" And I am on the "local-topic" branch with upstream "origin/remote-topic" Given the GitHub API server: """ get('/repos/github/hub/pulls'){ assert :head => "ashemesh:remote-topic" json [ { :html_url => "https://github.com/github/hub/pull/102" }, ] } """ When I successfully run `hub pr show` Then "open https://github.com/github/hub/pull/102" should be run Scenario: Upstream configuration with HTTPS URL Given I am on the "local-topic" branch When I successfully run `git config branch.local-topic.remote https://github.com/octocat/hub.git` When I successfully run `git config branch.local-topic.merge refs/remotes/remote-topic` Given the GitHub API server: """ get('/repos/ashemesh/hub/pulls'){ assert :head => "octocat:remote-topic" json [ { :html_url => "https://github.com/github/hub/pull/102" }, ] } """ When I successfully run `hub pr show` Then "open https://github.com/github/hub/pull/102" should be run Scenario: Upstream configuration with SSH URL Given I am on the "local-topic" branch When I successfully run `git config branch.local-topic.remote git@github.com:octocat/hub.git` When I successfully run `git config branch.local-topic.merge refs/remotes/remote-topic` Given the GitHub API server: """ get('/repos/ashemesh/hub/pulls'){ assert :head => "octocat:remote-topic" json [ { :html_url => "https://github.com/github/hub/pull/102" }, ] } """ When I successfully run `hub pr show` Then "open https://github.com/github/hub/pull/102" should be run Scenario: Explicit head branch Given the GitHub API server: """ get('/repos/ashemesh/hub/pulls'){ assert :state => "open", :head => "ashemesh:topic" json [ { :html_url => "https://github.com/ashemesh/hub/pull/102" }, ] } """ When I successfully run `hub pr show --head topic` Then "open https://github.com/ashemesh/hub/pull/102" should be run Scenario: Explicit head branch with owner Given the GitHub API server: """ get('/repos/ashemesh/hub/pulls'){ assert :state => "open", :head => "github:topic" json [ { :html_url => "https://github.com/ashemesh/hub/pull/102" }, ] } """ When I successfully run `hub pr show --head github:topic` Then "open https://github.com/ashemesh/hub/pull/102" should be run Scenario: No pull request found Given the GitHub API server: """ get('/repos/ashemesh/hub/pulls'){ json [] } """ When I run `hub pr show --head topic` Then the exit status should be 1 And the stderr should contain exactly: """ no open pull requests found for branch 'ashemesh:topic'\n """ Scenario: Show pull request by number When I successfully run `hub pr show 102` Then "open https://github.com/ashemesh/hub/pull/102" should be run Scenario: Format pull request by number Given the GitHub API server: """ get('/repos/ashemesh/hub/pulls/102') { json :number => 102, :title => "First", :state => "open", :base => { :ref => "master", :label => "github:master", :repo => { :owner => { :login => "github" } } }, :head => { :ref => "patch-1", :label => "octocat:patch-1" }, :user => { :login => "octocat" }, :requested_reviewers => [ { :login => "rey" }, ], :requested_teams => [ { :slug => "troopers" }, { :slug => "cantina-band" }, ] } """ When I successfully run `hub pr show 102 -f "%sC%>(8)%i %rs%n"` Then "open https://github.com/ashemesh/hub/pull/102" should not be run And the output should contain exactly: """ #102 rey, github/troopers, github/cantina-band\n\n """ Scenario: Show pull request by invalid number When I run `hub pr show XYZ` Then the exit status should be 1 And the stderr should contain exactly: """ invalid pull request number: 'XYZ'\n """ ================================================ FILE: features/pull_request.feature ================================================ Feature: hub pull-request Background: Given I am in "git://github.com/mislav/coral.git" git repo And I am "mislav" on github.com with OAuth token "OTOKEN" And the git commit editor is "vim" Scenario: Basic pull request Given I am on the "topic" branch pushed to "origin/topic" Given the GitHub API server: """ KNOWN_PARAMS = %w[title body base head draft issue maintainer_can_modify] post('/repos/mislav/coral/pulls') { halt 400 unless request.env['HTTP_ACCEPT'] == 'application/vnd.github.shadow-cat-preview+json;charset=utf-8' halt 400 unless request.user_agent.include?('Hub') halt 400 if (params.keys - KNOWN_PARAMS).any? assert :title => 'hello', :body => nil, :base => 'master', :head => 'mislav:topic', :maintainer_can_modify => true, :draft => nil, :issue => nil status 201 json :html_url => "the://url" } """ When I successfully run `hub pull-request -m hello` Then the output should contain exactly "the://url\n" Scenario: Detached HEAD Given I am in detached HEAD When I run `hub pull-request` Then the stderr should contain "Aborted: not currently on any branch.\n" And the exit status should be 1 Scenario: Detached HEAD with explicit head Given I am in detached HEAD Given the GitHub API server: """ post('/repos/mislav/coral/pulls') { assert :head => 'mislav:feature' status 201 json :html_url => "the://url" } """ When I successfully run `hub pull-request -h feature -m message` Then the output should contain exactly "the://url\n" Scenario: Non-GitHub repo Given the "origin" remote has url "mygh:Manganeez/repo.git" When I run `hub pull-request` Then the stderr should contain exactly: """ Aborted: could not find any git remote pointing to a GitHub repository\n """ And the exit status should be 1 Scenario: Create pull request respecting "insteadOf" configuration Given the "origin" remote has url "mygh:Manganeez/repo.git" When I successfully run `git config url."git@github.com:".insteadOf mygh:` Given I am on the "topic" branch pushed to "origin/topic" Given the GitHub API server: """ post('/repos/Manganeez/repo/pulls') { assert :base => 'master', :head => 'Manganeez:topic', :title => 'here we go' status 201 json :html_url => "https://github.com/Manganeez/repo/pull/12" } """ When I successfully run `hub pull-request -m "here we go"` Then the output should contain exactly "https://github.com/Manganeez/repo/pull/12\n" Scenario: With Unicode characters Given I am on the "topic" branch pushed to "origin/topic" Given the GitHub API server: """ post('/repos/mislav/coral/pulls') { halt 400 if request.content_charset != 'utf-8' assert :title => 'ăéñøü' status 201 json :html_url => "the://url" } """ When I successfully run `hub pull-request -m ăéñøü` Then the output should contain exactly "the://url\n" Scenario: Invalid flag When I run `hub pull-request -yelp` Then the stderr should contain "unknown shorthand flag: 'y' in -yelp\n" And the exit status should be 1 Scenario: With Unicode characters in the changelog Given the text editor adds: """ I <3 encodings """ Given the GitHub API server: """ post('/repos/mislav/coral/pulls') { halt 400 if request.content_charset != 'utf-8' assert :title => 'I <3 encodings', :body => 'ăéñøü' status 201 json :html_url => "the://url" } """ Given I am on the "master" branch pushed to "origin/master" When I successfully run `git checkout --quiet -b topic` Given I make a commit with message "ăéñøü" And the "topic" branch is pushed to "origin/topic" When I successfully run `hub pull-request` Then the output should contain exactly "the://url\n" Scenario: Default message for single-commit pull request Given the text editor adds: """ """ Given the GitHub API server: """ post('/repos/mislav/coral/pulls') { halt 400 if request.content_charset != 'utf-8' assert :title => 'This is somewhat of a longish title that does not get wrapped & references #1234', :body => 'Hello' status 201 json :html_url => "the://url" } """ Given I am on the "master" branch pushed to "origin/master" When I successfully run `git checkout --quiet -b topic` Given I make a commit with message: """ This is somewhat of a longish title that does not get wrapped & references #1234 Hello Signed-off-by: NAME Co-authored-by: NAME """ And the "topic" branch is pushed to "origin/topic" When I successfully run `hub pull-request` Then the output should contain exactly "the://url\n" Scenario: Single-commit with pull request template Given the git commit editor is "true" Given the GitHub API server: """ post('/repos/mislav/coral/pulls') { halt 400 if request.content_charset != 'utf-8' assert :title => 'Commit title', :body => < "the://url" } """ Given I am on the "master" branch pushed to "origin/master" When I successfully run `git checkout --quiet -b topic` And I make a commit with message: """ Commit title Commit body """ And the "topic" branch is pushed to "origin/topic" Given a file named "pull_request_template.md" with: """ This is the pull request template Another line of template """ When I successfully run `hub pull-request` Then the output should contain exactly "the://url\n" Scenario: Single-commit with PULL_REQUEST_TEMPLATE directory Given the git commit editor is "true" Given the GitHub API server: """ post('/repos/mislav/coral/pulls') { assert :title => 'Commit title', :body => 'Commit body' status 201 json :html_url => "the://url" } """ Given I am on the "master" branch pushed to "origin/master" When I successfully run `git checkout --quiet -b topic` And I make a commit with message: """ Commit title Commit body """ And the "topic" branch is pushed to "origin/topic" And a directory named "PULL_REQUEST_TEMPLATE" When I successfully run `hub pull-request` Then the output should contain exactly "the://url\n" Scenario: Single-commit pull request with "--no-edit" Given the GitHub API server: """ post('/repos/mislav/coral/pulls') { assert :title => 'Commit title 1', :body => 'Commit body 1' status 201 json :html_url => "the://url" } """ Given I am on the "master" branch pushed to "origin/master" When I successfully run `git checkout --quiet -b topic` Given I make a commit with message: """ Commit title 1 Commit body 1 """ And the "topic" branch is pushed to "origin/topic" When I successfully run `hub pull-request --no-edit` Then the output should contain exactly "the://url\n" Scenario: Multiple-commit pull request with "--no-edit" Given the GitHub API server: """ post('/repos/mislav/coral/pulls') { assert :title => 'Commit title 1', :body => 'Commit body 1' status 201 json :html_url => "the://url" } """ Given I am on the "master" branch pushed to "origin/master" When I successfully run `git checkout --quiet -b topic` Given I make a commit with message: """ Commit title 1 Commit body 1 """ Given I make a commit with message: """ Commit title 2 Commit body 2 """ And the "topic" branch is pushed to "origin/topic" When I successfully run `hub pull-request --no-edit` Then the output should contain exactly "the://url\n" Scenario: Pull request with "--push" and "--no-edit" Given the GitHub API server: """ post('/repos/mislav/coral/pulls') { assert :title => 'Commit title 1', :body => 'Commit body 1' status 201 json :html_url => "the://url" } """ Given I am on the "master" branch pushed to "origin/master" When I successfully run `git checkout --quiet -b topic` Given I make a commit with message: """ Commit title 1 Commit body 1 """ When I successfully run `hub pull-request --push --no-edit` Then the output should contain exactly "the://url\n" Scenario: No commits with "--no-edit" Given I am on the "master" branch pushed to "origin/master" When I successfully run `git checkout --quiet -b topic` Given the "topic" branch is pushed to "origin/topic" When I run `hub pull-request --no-edit` Then the exit status should be 1 And the stderr should contain exactly: """ Aborted: no commits detected between origin/master and topic\n """ Scenario: Message template should include git log summary between base and head Given the text editor adds: """ Hello """ Given the GitHub API server: """ post('/repos/mislav/coral/pulls') { status 500 } """ Given I am on the "master" branch And I make a commit with message "One on master" And I make a commit with message "Two on master" And the "master" branch is pushed to "origin/master" Given I successfully run `git reset --hard HEAD~2` And I successfully run `git checkout --quiet -B topic origin/master` Given I make a commit with message "One on topic" And I make a commit with message "Two on topic" Given the "topic" branch is pushed to "origin/topic" And I successfully run `git reset --hard HEAD~1` When I run `hub pull-request` Given the SHAs and timestamps are normalized in ".git/PULLREQ_EDITMSG" Then the file ".git/PULLREQ_EDITMSG" should contain exactly: """ Hello # ------------------------ >8 ------------------------ # Do not modify or remove the line above. # Everything below it will be ignored. Requesting a pull to mislav:master from mislav:topic Write a message for this pull request. The first block of text is the title and the rest is the description. Changes: SHA1SHA (Hub, 0 seconds ago) Two on topic SHA1SHA (Hub, 0 seconds ago) One on topic """ Scenario: Non-existing base Given the GitHub API server: """ post('/repos/origin/coral/pulls') { 404 } """ When I run `hub pull-request -b origin:master -h topic -m here` Then the exit status should be 1 Then the stderr should contain: """ Error creating pull request: Not Found (HTTP 404) Are you sure that github.com/origin/coral exists? """ Scenario: Text editor adds title and body Given I am on the "topic" branch pushed to "origin/topic" Given the text editor adds: """ This title comes from vim! This body as well. """ Given the GitHub API server: """ post('/repos/mislav/coral/pulls') { assert :title => 'This title comes from vim!', :body => 'This body as well.' status 201 json :html_url => "https://github.com/mislav/coral/pull/12" } """ When I successfully run `hub pull-request` Then the output should contain exactly "https://github.com/mislav/coral/pull/12\n" And the file ".git/PULLREQ_EDITMSG" should not exist Scenario: Text editor adds title and body with multiple lines Given I am on the "topic" branch pushed to "origin/topic" Given the text editor adds: """ This title is on the third line This body has multiple lines. """ Given the GitHub API server: """ post('/repos/mislav/coral/pulls') { assert :title => 'This title is on the third line', :body => "This body\n\n\nhas multiple\nlines." status 201 json :html_url => "https://github.com/mislav/coral/pull/12" } """ When I successfully run `hub pull-request` Then the output should contain exactly "https://github.com/mislav/coral/pull/12\n" And the file ".git/PULLREQ_EDITMSG" should not exist Scenario: Text editor with custom commentchar Given I am on the "topic" branch pushed to "origin/topic" Given git "core.commentchar" is set to "/" And the text editor adds: """ # Dat title / This line is not commented out. Dem body. """ Given the GitHub API server: """ post('/repos/mislav/coral/pulls') { assert :title => '# Dat title', :body => "/ This line is not commented out.\n\nDem body." status 201 json :html_url => "the://url" } """ When I successfully run `hub pull-request` Then the output should contain exactly "the://url\n" Scenario: Failed pull request preserves previous message Given I am on the "topic" branch pushed to "origin/topic" Given the text editor adds: """ This title will fail """ Given the GitHub API server: """ post('/repos/mislav/coral/pulls') { halt 422 if params[:title].include?("fail") assert :body => "This title will fail", :title => "But this title will prevail" status 201 json :html_url => "https://github.com/mislav/coral/pull/12" } """ When I run `hub pull-request` Then the exit status should be 1 And the stderr should contain exactly: """ Error creating pull request: Unprocessable Entity (HTTP 422)\n """ Given the text editor adds: """ But this title will prevail """ When I successfully run `hub pull-request` Then the file ".git/PULLREQ_EDITMSG" should not exist Scenario: Text editor fails Given I am on the "topic" branch pushed to "origin/topic" Given the text editor exits with error status And an empty file named ".git/PULLREQ_EDITMSG" When I run `hub pull-request` Then the stderr should contain "error using text editor for pull request message" And the exit status should be 1 And the file ".git/PULLREQ_EDITMSG" should not exist Scenario: Title and body from file Given I am on the "topic" branch pushed to "origin/topic" Given the GitHub API server: """ post('/repos/mislav/coral/pulls') { assert :title => 'Title from file', :body => "Body from file as well.\n\nMultiline, even!" status 201 json :html_url => "https://github.com/mislav/coral/pull/12" } """ And a file named "pullreq-msg" with: """ Title from file Body from file as well. Multiline, even! """ When I successfully run `hub pull-request -F pullreq-msg` Then the output should contain exactly "https://github.com/mislav/coral/pull/12\n" And the file ".git/PULLREQ_EDITMSG" should not exist Scenario: Edit title and body from file Given I am on the "topic" branch pushed to "origin/topic" Given the GitHub API server: """ post('/repos/mislav/coral/pulls') { assert :title => 'Hello from editor', :body => "Title from file\n\nBody from file as well." status 201 json :html_url => "https://github.com/mislav/coral/pull/12" } """ And a file named "pullreq-msg" with: """ Title from file Body from file as well. """ And the text editor adds: """ Hello from editor """ When I successfully run `hub pull-request -F pullreq-msg --edit` Then the file ".git/PULLREQ_EDITMSG" should not exist Scenario: Title and body from stdin Given I am on the "topic" branch pushed to "origin/topic" Given the GitHub API server: """ post('/repos/mislav/coral/pulls') { assert :title => 'Unix piping is great', :body => 'Just look at this ăéñøü' status 201 json :html_url => "https://github.com/mislav/coral/pull/12" } """ When I run `hub pull-request -F -` interactively And I pass in: """ Unix piping is great Just look at this ăéñøü """ Then the output should contain exactly "https://github.com/mislav/coral/pull/12\n" And the exit status should be 0 And the file ".git/PULLREQ_EDITMSG" should not exist Scenario: Title and body from command-line argument Given I am on the "topic" branch pushed to "origin/topic" Given the GitHub API server: """ post('/repos/mislav/coral/pulls') { assert :title => 'I am just a pull', :body => 'A little pull' status 201 json :html_url => "https://github.com/mislav/coral/pull/12" } """ When I successfully run `hub pull-request -m "I am just a pull\n\nA little pull"` Then the output should contain exactly "https://github.com/mislav/coral/pull/12\n" And the file ".git/PULLREQ_EDITMSG" should not exist Scenario: Title and body from multiple command-line arguments Given I am on the "topic" branch pushed to "origin/topic" Given the GitHub API server: """ post('/repos/mislav/coral/pulls') { assert :title => 'I am just a pull', :body => "A little pull\n\nAnd description" status 201 json :html_url => "https://github.com/mislav/coral/pull/12" } """ When I successfully run `hub pull-request -m "I am just a pull" -m "A little pull" -m "And description"` Then the output should contain exactly "https://github.com/mislav/coral/pull/12\n" Scenario: Error when implicit head is the same as base Given I am on the "master" branch with upstream "origin/master" When I run `hub pull-request` Then the stderr should contain exactly: """ Aborted: head branch is the same as base ("master") (use `-h ` to specify an explicit pull request head)\n """ Scenario: Explicit head Given I am on the "master" branch Given the GitHub API server: """ post('/repos/mislav/coral/pulls') { assert :head => 'mislav:feature' status 201 json :html_url => "the://url" } """ When I successfully run `hub pull-request -h feature -m message` Then the output should contain exactly "the://url\n" Scenario: Explicit head with owner Given I am on the "master" branch Given the GitHub API server: """ post('/repos/mislav/coral/pulls') { assert :head => 'mojombo:feature' status 201 json :html_url => "the://url" } """ When I successfully run `hub pull-request -h mojombo:feature -m message` Then the output should contain exactly "the://url\n" Scenario: Explicit base Given I am on the "feature" branch pushed to "origin/feature" Given the GitHub API server: """ post('/repos/mislav/coral/pulls') { assert :base => 'develop', :head => 'mislav:feature' status 201 json :html_url => "the://url" } """ When I successfully run `hub pull-request -b develop -m message` Then the output should contain exactly "the://url\n" Scenario: Implicit base by detecting main branch Given the default branch for "origin" is "develop" And the "master" branch is pushed to "origin/master" Given the GitHub API server: """ post('/repos/mislav/coral/pulls') { assert :base => 'develop', :head => 'mislav:master' status 201 json :html_url => "the://url" } """ When I successfully run `hub pull-request -m message` Then the output should contain exactly "the://url\n" Scenario: Explicit base with owner Given I am on the "master" branch pushed to "origin/master" Given the GitHub API server: """ post('/repos/mojombo/coral/pulls') { assert :base => 'develop' status 201 json :html_url => "the://url" } """ When I successfully run `hub pull-request -b mojombo:develop -m message` Then the output should contain exactly "the://url\n" Scenario: Explicit base with owner and repo name Given I am on the "master" branch pushed to "origin/master" Given the GitHub API server: """ post('/repos/mojombo/coralify/pulls') { assert :base => 'develop' status 201 json :html_url => "the://url" } """ When I successfully run `hub pull-request -b mojombo/coralify:develop -m message` Then the output should contain exactly "the://url\n" Scenario: Error when there are unpushed commits Given I am on the "feature" branch with upstream "origin/feature" When I make 2 commits And I run `hub pull-request` Then the stderr should contain exactly: """ Aborted: 2 commits are not yet pushed to origin/feature (use `-f` to force submit a pull request anyway)\n """ Scenario: Ignore unpushed commits with `-f` Given I am on the "feature" branch with upstream "origin/feature" Given the GitHub API server: """ post('/repos/mislav/coral/pulls') { assert :head => 'mislav:feature' status 201 json :html_url => "the://url" } """ When I make 2 commits And I successfully run `hub pull-request -f -m message` Then the output should contain exactly "the://url\n" Scenario: Error from an unpushed branch Given I am on the "feature" branch When I run `hub pull-request -m hello` Then the exit status should be 1 And the stderr should contain exactly: """ Aborted: the current branch seems not yet pushed to a remote (use `-p` to push the branch or `-f` to skip this check)\n """ Scenario: Error from an unpushed branch with upstream same as base branch Given I am on the "feature" branch with upstream "origin/master" When I run `hub pull-request -m hello` Then the exit status should be 1 And the stderr should contain exactly: """ Aborted: the current branch seems not yet pushed to a remote (use `-p` to push the branch or `-f` to skip this check)\n """ Scenario: Pull request fails on the server Given I am on the "feature" branch with upstream "origin/feature" Given the GitHub API server: """ tries = 0 post('/repos/mislav/coral/pulls') { tries += 1 if tries == 1 status 422 json :message => 'Validation Failed', :errors => [{ :resource => 'PullRequest', :code => 'invalid', :field => 'head' }] else status 400 end } """ When I run `hub pull-request -m message` Then the stderr should contain exactly: """ Error creating pull request: Unprocessable Entity (HTTP 422) Invalid value for "head"\n """ And the exit status should be 1 Scenario: Convert issue to pull request Given I am on the "feature" branch with upstream "origin/feature" Given the GitHub API server: """ post('/repos/mislav/coral/pulls') { assert :issue => 92 status 201 json :html_url => "https://github.com/mislav/coral/pull/92" } """ When I successfully run `hub pull-request -i 92` Then the output should contain exactly: """ https://github.com/mislav/coral/pull/92\n """ Scenario: Convert issue URL to pull request Given I am on the "feature" branch with upstream "origin/feature" Given the GitHub API server: """ post('/repos/mislav/coral/pulls') { assert :issue => 92 status 201 json :html_url => "https://github.com/mislav/coral/pull/92" } """ When I successfully run `hub pull-request https://github.com/mislav/coral/issues/92` Then the output should contain exactly: """ https://github.com/mislav/coral/pull/92\n """ Scenario: Enterprise host Given the "origin" remote has url "git@git.my.org:mislav/coral.git" And I am "mislav" on git.my.org with OAuth token "FITOKEN" And "git.my.org" is a whitelisted Enterprise host And I am on the "topic" branch pushed to "origin/topic" Given the GitHub API server: """ post('/api/v3/repos/mislav/coral/pulls', :host_name => 'git.my.org') { assert :base => 'master', :head => 'mislav:topic' status 201 json :html_url => "the://url" } """ When I successfully run `hub pull-request -m enterprisey` Then the output should contain exactly "the://url\n" Scenario: Enterprise remote witch matching branch but no tracking Given the "origin" remote has url "git@git.my.org:mislav/coral.git" And I am "mislav" on git.my.org with OAuth token "FITOKEN" And "git.my.org" is a whitelisted Enterprise host And I am on the "feature" branch pushed to "origin/feature" Given the GitHub API server: """ post('/api/v3/repos/mislav/coral/pulls', :host_name => 'git.my.org') { assert :base => 'master', :head => 'mislav:feature' status 201 json :html_url => "the://url" } """ When I successfully run `hub pull-request -m enterprisey` Then the output should contain exactly "the://url\n" Scenario: Create pull request from branch on the same remote Given the "origin" remote has url "git://github.com/github/coral.git" And the "mislav" remote has url "git://github.com/mislav/coral.git" And I am on the "feature" branch pushed to "origin/feature" Given the GitHub API server: """ post('/repos/github/coral/pulls') { assert :base => 'master', :head => 'github:feature', :title => 'hereyougo' status 201 json :html_url => "the://url" } """ When I successfully run `hub pull-request -m hereyougo` Then the output should contain exactly "the://url\n" Scenario: Create pull request from branch on the personal fork case sensitive Given the "origin" remote has url "git://github.com/github/coral.git" And the "doge" remote has url "git://github.com/Mislav/coral.git" And I am on the "feature" branch pushed to "doge/feature" Given the GitHub API server: """ post('/repos/github/coral/pulls') { assert :base => 'master', :head => 'Mislav:feature', :title => 'hereyougo' status 201 json :html_url => "the://url" } """ When I successfully run `hub pull-request -m hereyougo` Then the output should contain exactly "the://url\n" Scenario: Create pull request from branch on the personal fork Given the "origin" remote has url "git://github.com/github/coral.git" And the "doge" remote has url "git://github.com/mislav/coral.git" And I am on the "feature" branch pushed to "doge/feature" Given the GitHub API server: """ post('/repos/github/coral/pulls') { assert :base => 'master', :head => 'mislav:feature', :title => 'hereyougo' status 201 json :html_url => "the://url" } """ When I successfully run `hub pull-request -m hereyougo` Then the output should contain exactly "the://url\n" Scenario: Create pull request from branch on the personal fork, capitalized Given the "origin" remote has url "git://github.com/LightAlf/FirstRepo.git" And the "Kristinita" remote has url "git@github.com:Kristinita/FirstRepo.git" And I am on the "add-py3kwarn" branch pushed to "Kristinita/add-py3kwarn" And I am "Kristinita" on github.com with OAuth token "OTOKEN" Given the GitHub API server: """ post('/repos/LightAlf/FirstRepo/pulls') { assert :base => 'master', :head => 'Kristinita:add-py3kwarn', :title => 'hereyougo' status 201 json :html_url => "the://url" } """ When I successfully run `hub pull-request -m hereyougo` Then the output should contain exactly "the://url\n" Scenario: Create pull request to "upstream" remote Given the "upstream" remote has url "git://github.com/github/coral.git" And I am on the "master" branch pushed to "origin/master" Given the GitHub API server: """ post('/repos/github/coral/pulls') { assert :base => 'master', :head => 'mislav:master', :title => 'hereyougo' status 201 json :html_url => "the://url" } """ When I successfully run `hub pull-request -m hereyougo` Then the output should contain exactly "the://url\n" Scenario: Create pull request to "upstream" remote with differently-named default branch Given I am on the "master" branch pushed to "origin/master" And the "upstream" remote has url "git://github.com/github/coral.git" And the default branch for "upstream" is "develop" Given the GitHub API server: """ post('/repos/github/coral/pulls') { assert :base => 'develop', :head => 'mislav:master', :title => 'hereyougo' status 201 json :html_url => "the://url" } """ When I successfully run `hub pull-request -m hereyougo` Then the output should contain exactly "the://url\n" Scenario: Create pull request to "github" remote when "upstream" is non-GitHub Given I am on the "master" branch pushed to "origin/master" And the "github" remote has url "git://github.com/github/coral.git" And the "upstream" remote has url "git://example.com/coral.git" Given the GitHub API server: """ post('/repos/github/coral/pulls') { assert :base => 'master', :head => 'mislav:master', :title => 'hereyougo' status 201 json :html_url => "the://url" } """ When I successfully run `hub pull-request -m hereyougo` Then the output should contain exactly "the://url\n" Scenario: Create pull request to "github" remote when "origin" is non-GitHub Given the "github" remote has url "git@github.com:sam-hart-swanson/debug.git" Given the "origin" remote has url "ssh://git@private.server.com/path/to/repo.git" And I am on the "feat/123-some-branch" branch pushed to "github/feat/123-some-branch" And an empty file named ".git/refs/remotes/origin/feat/123-some-branch" Given the GitHub API server: """ post('/repos/sam-hart-swanson/debug/pulls') { assert :base => 'master', :head => 'sam-hart-swanson:feat/123-some-branch', :title => 'hereyougo' status 201 json :html_url => "the://url" } """ When I successfully run `hub pull-request -m hereyougo` Then the output should contain exactly "the://url\n" Scenario: Open pull request in web browser Given I am on the "topic" branch pushed to "origin/topic" Given the GitHub API server: """ post('/repos/mislav/coral/pulls') { status 201 json :html_url => "the://url" } """ When I successfully run `hub pull-request -o -m hereyougo` Then "open the://url" should be run Scenario: Current branch is tracking local branch Given git "push.default" is set to "upstream" And I am on the "feature" branch pushed to "origin/feature" When I successfully run `git config branch.feature.remote .` When I successfully run `git config branch.feature.merge refs/heads/master` Given the GitHub API server: """ post('/repos/mislav/coral/pulls') { assert :base => 'master', :head => 'mislav:feature' status 201 json :html_url => "the://url" } """ When I successfully run `hub pull-request -m hereyougo` Then the output should contain exactly "the://url\n" Scenario: Current branch is pushed to remote without upstream configuration Given the "upstream" remote has url "git://github.com/lestephane/coral.git" And I am on the "feature" branch pushed to "origin/feature" And git "push.default" is set to "upstream" Given the GitHub API server: """ post('/repos/lestephane/coral/pulls') { assert :base => 'master', :head => 'mislav:feature' status 201 json :html_url => "the://url" } """ When I successfully run `hub pull-request -m hereyougo` Then the output should contain exactly "the://url\n" Scenario: Branch with quotation mark in name Given I am on the "feat'ure" branch with upstream "origin/feat'ure" Given the GitHub API server: """ post('/repos/mislav/coral/pulls') { assert :head => "mislav:feat'ure" status 201 json :html_url => "the://url" } """ When I successfully run `hub pull-request -m hereyougo` Then the output should contain exactly "the://url\n" Scenario: Pull request with assignees Given I am on the "feature" branch with upstream "origin/feature" Given the GitHub API server: """ post('/repos/mislav/coral/pulls') { assert :head => "mislav:feature" status 201 json :html_url => "the://url", :number => 1234 } patch('/repos/mislav/coral/issues/1234') { assert :assignees => ["mislav", "josh", "pcorpet"], :labels => :no json :html_url => "the://url" } """ When I successfully run `hub pull-request -m hereyougo -a mislav,josh -apcorpet` Then the output should contain exactly "the://url\n" Scenario: Pull request with reviewers Given I am on the "feature" branch with upstream "origin/feature" Given the GitHub API server: """ post('/repos/mislav/coral/pulls') { assert :head => "mislav:feature" status 201 json :html_url => "the://url", :number => 1234 } post('/repos/mislav/coral/pulls/1234/requested_reviewers') { assert :reviewers => ["mislav", "josh", "pcorpet"] assert :team_reviewers => ["robots", "js"] status 201 json :html_url => "the://url" } """ When I successfully run `hub pull-request -m hereyougo -r mislav,josh -rgithub/robots -rpcorpet -r github/js` Then the output should contain exactly "the://url\n" Scenario: Pull request with reviewers from CODEOWNERS Given I am on the "feature" branch with upstream "origin/feature" Given the GitHub API server: """ post('/repos/mislav/coral/pulls') { assert :head => "mislav:feature" status 201 json :html_url => "the://url", :number => 1234, :requested_reviewers => [{ :login => "josh" }], :requested_teams => [{ :slug => "robots" }] } post('/repos/mislav/coral/pulls/1234/requested_reviewers') { assert :reviewers => ["mislav", "pcorpet"] assert :team_reviewers => ["js"] status 201 json :html_url => "the://url" } """ When I successfully run `hub pull-request -m hereyougo -r mislav,josh -rgithub/robots -rpcorpet -r github/js` Then the output should contain exactly "the://url\n" Scenario: Pull request avoids re-requesting reviewers Given I am on the "feature" branch with upstream "origin/feature" Given the GitHub API server: """ post('/repos/mislav/coral/pulls') { assert :head => "mislav:feature" status 201 json :html_url => "the://url", :number => 1234, :requested_reviewers => [{ :login => "josh" }, { :login => "mislav" }], :requested_teams => [{ :slug => "robots" }] } """ When I successfully run `hub pull-request -m hereyougo -r mislav,josh -rgithub/robots` Then the output should contain exactly "the://url\n" Scenario: Requesting reviewers failed Given I am on the "feature" branch with upstream "origin/feature" Given the GitHub API server: """ post('/repos/mislav/coral/pulls') { status 201 json :html_url => "the://url", :number => 1234 } post('/repos/mislav/coral/pulls/1234/requested_reviewers') { status 422 json :message => "Validation Failed", :errors => ["Could not add requested reviewers to pull request."], :documentation_url => "https://developer.github.com/v3/pulls/review_requests/#create-a-review-request" } """ When I run `hub pull-request -m hereyougo -r pedrohc` Then the exit status should be 1 And the stderr should contain exactly: """ Error requesting reviewer: Unprocessable Entity (HTTP 422) Could not add requested reviewers to pull request.\n """ Scenario: Pull request with milestone Given I am on the "feature" branch with upstream "origin/feature" Given the GitHub API server: """ get('/repos/mislav/coral/milestones') { status 200 json [ { :number => 237, :title => "prerelease" }, { :number => 1337, :title => "v1" }, { :number => 41319, :title => "Hello World!" } ] } post('/repos/mislav/coral/pulls') { assert :head => "mislav:feature" status 201 json :html_url => "the://url", :number => 1234 } patch('/repos/mislav/coral/issues/1234') { assert :milestone => 41319 json :html_url => "the://url" } """ When I successfully run `hub pull-request -m hereyougo -M "Hello World!"` Then the output should contain exactly "the://url\n" Scenario: Pull request with case-insensitive milestone Given I am on the "feature" branch with upstream "origin/feature" Given the GitHub API server: """ get('/repos/mislav/coral/milestones') { status 200 json [ { :number => 237, :title => "prerelease" }, { :number => 1337, :title => "v1" }, { :number => 41319, :title => "Hello World!" } ] } post('/repos/mislav/coral/pulls') { assert :head => "mislav:feature" status 201 json :html_url => "the://url", :number => 1234 } patch('/repos/mislav/coral/issues/1234') { assert :milestone => 41319 json :html_url => "the://url" } """ When I successfully run `hub pull-request -m hereyougo -M "hello world!"` Then the output should contain exactly "the://url\n" Scenario: Pull request uses integer milestone number for BC Given I am on the "feature" branch with upstream "origin/feature" Given the GitHub API server: """ get('/repos/mislav/coral/milestones') { status 200 json [{ :number => 237, :title => "prerelease" }] } post('/repos/mislav/coral/pulls') { assert :head => "mislav:feature" status 201 json :html_url => "the://url", :number => 1234 } patch('/repos/mislav/coral/issues/1234') { assert :milestone => 55 json :html_url => "the://url" } """ When I successfully run `hub pull-request -m hereyougo -M 55` Then the output should contain exactly "the://url\n" Scenario: Pull request fails with unknown milestone before it's created Given I am on the "feature" branch with upstream "origin/feature" Given the GitHub API server: """ get('/repos/mislav/coral/milestones') { status 200 json [] } """ When I run `hub pull-request -m hereyougo -M "unknown"` Then the exit status should be 1 And the stderr should contain exactly "error: no milestone found with name 'unknown'\n" Scenario: Pull request with labels Given I am on the "feature" branch with upstream "origin/feature" Given the GitHub API server: """ post('/repos/mislav/coral/pulls') { assert :head => "mislav:feature" status 201 json :html_url => "the://url", :number => 1234 } patch('/repos/mislav/coral/issues/1234') { assert :labels => ["feature", "release", "docs"], :assignees => :no json :html_url => "the://url" } """ When I successfully run `hub pull-request -m hereyougo -l feature,release -ldocs` Then the output should contain exactly "the://url\n" Scenario: Pull request to a fetch-only upstream Given the "upstream" remote has url "git://github.com/github/coral.git" And the "upstream" remote has push url "no_push" And I am on the "master" branch pushed to "origin/master" Given the GitHub API server: """ post('/repos/github/coral/pulls') { assert :base => 'master', :head => 'mislav:master', :title => 'hereyougo' status 201 json :html_url => "the://url" } """ When I successfully run `hub pull-request -m hereyougo` Then the output should contain exactly "the://url\n" Scenario: Pull request with 307 redirect Given the "origin" remote has url "https://github.com/mislav/coral.git" And I am on the "feature" branch pushed to "origin/feature" Given the GitHub API server: """ get('/repos/mislav/coral') { redirect 'https://api.github.com/repositories/12345', 301 } get('/repositories/12345') { json :name => 'coralify', :owner => { :login => 'coral-org' } } post('/repos/mislav/coral/pulls') { redirect 'https://api.github.com/repositories/12345', 307 } post('/repositories/12345', :host_name => 'api.github.com') { assert :base => 'master', :head => 'coral-org:feature', :title => 'hereyougo' status 201 json :html_url => "the://url" } """ When I successfully run `hub pull-request -m hereyougo` Then the output should contain exactly "the://url\n" Scenario: Pull request with 301 redirect Given the "origin" remote has url "https://github.com/mislav/coral.git" And I am on the "feature" branch pushed to "origin/feature" Given the GitHub API server: """ get('/repos/mislav/coral') { redirect 'https://api.github.com/repositories/12345', 301 } get('/repositories/12345') { json :name => 'coralify', :owner => { :login => 'coral-org' } } post('/repos/mislav/coral/pulls') { redirect 'https://api.github.com/repositories/12345/pulls', 301 } post('/repositories/12345/pulls', :host_name => 'api.github.com') { assert :base => 'master', :head => 'coral-org:feature', :title => 'hereyougo' status 201 json :html_url => "the://url" } """ When I run `hub pull-request -m hereyougo` Then the exit status should be 1 And stderr should contain exactly: """ Error creating pull request: Post https://api.github.com/repositories/12345/pulls: refusing to follow HTTP 301 redirect for a POST request Have your site admin use HTTP 308 for this kind of redirect\n """ Scenario: Default message with --push Given the git commit editor is "true" Given the GitHub API server: """ post('/repos/mislav/coral/pulls') { assert :title => 'The commit I never pushed', :body => nil status 201 json :html_url => "the://url" } """ Given I am on the "master" branch pushed to "origin/master" When I successfully run `git checkout --quiet -b topic` Given I make a commit with message "The commit I never pushed" When I successfully run `hub pull-request -p` Then the output should contain exactly "the://url\n" And "git push --set-upstream origin HEAD:topic" should be run Scenario: Text editor fails with --push Given the text editor exits with error status And I am on the "master" branch pushed to "origin/master" And an empty file named ".git/PULLREQ_EDITMSG" When I successfully run `git checkout --quiet -b topic` Given I make a commit When I run `hub pull-request -p` Then the stderr should contain "error using text editor for pull request message" And the exit status should be 1 And the file ".git/PULLREQ_EDITMSG" should not exist And "git push --set-upstream origin HEAD:topic" should not be run Scenario: Triangular workflow with --push Given the "upstream" remote has url "git://github.com/github/coral.git" And I am on the "master" branch pushed to "upstream/master" # TODO: head should be "mislav:topic" Given the GitHub API server: """ post('/repos/github/coral/pulls') { assert :base => 'master', :head => 'github:topic', :title => 'hereyougo' status 201 json :html_url => "the://url" } """ When I successfully run `git checkout --quiet -b topic` Given I make a commit with message "Fork commit" When I successfully run `hub pull-request -p -m hereyougo` Then the output should contain exactly "the://url\n" # TODO: the push should be to the "origin" remote instead And "git push --set-upstream upstream HEAD:topic" should be run Scenario: Automatically retry when --push resulted in 422 Given the default aruba exit timeout is 7 seconds And the text editor adds: """ hello! """ Given the GitHub API server: """ first_try_at = nil tries = 0 post('/repos/mislav/coral/pulls') { tries += 1 assert :title => 'hello!', :head => 'mislav:topic' if !first_try_at || (Time.now - first_try_at) < 5 first_try_at ||= Time.now status 422 json :message => 'Validation Failed', :errors => [{ :resource => 'PullRequest', :code => 'invalid', :field => 'head' }] else status 201 json :html_url => "the://url?tries=#{tries}" end } """ Given I am on the "topic" branch When I successfully run `hub pull-request -p` Then the output should contain exactly "the://url?tries=3\n" And the file ".git/PULLREQ_EDITMSG" should not exist Scenario: Eventually give up on retries for --push Given the default aruba exit timeout is 7 seconds And the text editor adds: """ hello! """ And $HUB_RETRY_TIMEOUT is "5" Given the GitHub API server: """ post('/repos/mislav/coral/pulls') { status 422 json :message => 'Validation Failed', :errors => [{ :resource => 'PullRequest', :code => 'invalid', :field => 'head' }] } """ Given I am on the "topic" branch When I run `hub pull-request -p` Then the stderr should contain: """ Error creating pull request: Unprocessable Entity (HTTP 422) Invalid value for "head"\n """ And the output should match /Given up after retrying for 5\.\d seconds\./ And a file named ".git/PULLREQ_EDITMSG" should exist Scenario: Draft pull request Given I am on the "topic" branch pushed to "origin/topic" Given the GitHub API server: """ post('/repos/mislav/coral/pulls') { halt 400 unless request.env['HTTP_ACCEPT'] == 'application/vnd.github.shadow-cat-preview+json;charset=utf-8' assert :draft => true status 201 json :html_url => "the://url" } """ When I successfully run `hub pull-request -d -m wip` Then the output should contain exactly "the://url\n" Scenario: Disallow edits from maintainers Given I am on the "topic" branch pushed to "origin/topic" Given the GitHub API server: """ post('/repos/mislav/coral/pulls') { assert :maintainer_can_modify => false status 201 json :html_url => "the://url" } """ When I successfully run `hub pull-request -m hello --no-maintainer-edits` Then the output should contain exactly "the://url\n" ================================================ FILE: features/push.feature ================================================ Feature: hub push Background: Given I am in "git://github.com/mislav/coral.git" git repo Scenario: Normal push When I successfully run `hub push` Then the git command should be unchanged Scenario: Push current branch to multiple remotes Given I am on the "cool-feature" branch When I successfully run `hub push origin,staging` Then "git push origin cool-feature" should be run Then "git push staging cool-feature" should be run Scenario: Push explicit branch to multiple remotes When I successfully run `hub push origin,staging,qa cool-feature` Then "git push origin cool-feature" should be run Then "git push staging cool-feature" should be run Then "git push qa cool-feature" should be run Scenario: Push multiple refs to multiple remotes When I successfully run `hub push origin,staging master new-feature` Then "git push origin master new-feature" should be run Then "git push staging master new-feature" should be run ================================================ FILE: features/release.feature ================================================ Feature: hub release Background: Given I am in "git://github.com/mislav/will_paginate.git" git repo And I am "mislav" on github.com with OAuth token "OTOKEN" Scenario: List non-draft releases Given the GitHub API server: """ get('/repos/mislav/will_paginate/releases') { json [ { tag_name: 'v1.2.0', name: 'will_paginate 1.2.0', draft: true, prerelease: false, }, { tag_name: 'v1.2.0-pre', name: 'will_paginate 1.2.0-pre', draft: false, prerelease: true, }, { tag_name: 'v1.0.2', name: 'will_paginate 1.0.2', draft: false, prerelease: false, }, ] } """ When I successfully run `hub release` Then the output should contain exactly: """ v1.2.0-pre v1.0.2\n """ Scenario: List non-prerelease releases Given the GitHub API server: """ get('/repos/mislav/will_paginate/releases') { json [ { tag_name: 'v1.2.0', name: 'will_paginate 1.2.0', draft: true, prerelease: false, }, { tag_name: 'v1.2.0-pre', name: 'will_paginate 1.2.0-pre', draft: false, prerelease: true, }, { tag_name: 'v1.0.2', name: 'will_paginate 1.0.2', draft: false, prerelease: false, }, ] } """ When I successfully run `hub release --exclude-prereleases` Then the output should contain exactly: """ v1.0.2\n """ Scenario: List all releases Given the GitHub API server: """ get('/repos/mislav/will_paginate/releases') { json [ { tag_name: 'v1.2.0', name: 'will_paginate 1.2.0', draft: true, prerelease: false, }, { tag_name: 'v1.2.0-pre', name: 'will_paginate 1.2.0-pre', draft: false, prerelease: true, }, { tag_name: 'v1.0.2', name: 'will_paginate 1.0.2', draft: false, prerelease: false, }, ] } """ When I successfully run `hub release --include-drafts` Then the output should contain exactly: """ v1.2.0 v1.2.0-pre v1.0.2\n """ Scenario: Fetch releases across multiple pages Given the GitHub API server: """ get('/repos/mislav/will_paginate/releases') { assert :per_page => "100", :page => :no response.headers["Link"] = %(; rel="next") json [ { tag_name: 'v1.2.0', name: 'will_paginate 1.2.0', draft: false, prerelease: false, }, ] } get('/repositories/12345') { assert :per_page => "100" if params[:page] == "2" response.headers["Link"] = %(; rel="next") json [ { tag_name: 'v1.2.0-pre', name: 'will_paginate 1.2.0-pre', draft: false, prerelease: true, }, { tag_name: 'v1.0.2', name: 'will_paginate 1.0.2', draft: false, prerelease: false, }, ] elsif params[:page] == "3" json [ { tag_name: 'v1.0.0', name: 'will_paginate 1.0.0', draft: false, prerelease: true, }, ] else status 400 end } """ When I successfully run `hub release` Then the output should contain exactly: """ v1.2.0 v1.2.0-pre v1.0.2 v1.0.0\n """ Scenario: List limited number of releases Given the GitHub API server: """ get('/repos/mislav/will_paginate/releases') { response.headers["Link"] = %(; rel="next") assert :per_page => "3" json [ { tag_name: 'v1.2.0', name: 'will_paginate 1.2.0', draft: false, prerelease: false, }, { tag_name: 'v1.2.0-pre', name: 'will_paginate 1.2.0-pre', draft: false, prerelease: true, }, { tag_name: 'v1.0.2', name: 'will_paginate 1.0.2', draft: false, prerelease: false, }, ] } """ When I successfully run `hub release -L 2` Then the output should contain exactly: """ v1.2.0 v1.2.0-pre\n """ Scenario: Pretty-print releases Given the GitHub API server: """ get('/repos/mislav/will_paginate/releases') { json [ { tag_name: 'v1.2.0', name: 'will_paginate 1.2.0', draft: true, prerelease: false, created_at: '2018-02-27T19:35:32Z', published_at: '2018-04-01T19:35:32Z', assets: [ {browser_download_url: 'the://url', label: ''}, ], }, { tag_name: 'v1.2.0-pre', name: 'will_paginate 1.2.0-pre', draft: false, prerelease: true, }, { tag_name: 'v1.0.2', name: 'will_paginate 1.0.2', draft: false, prerelease: false, }, ] } """ When I successfully run `hub release --include-drafts --format='%t (%S)%n'` Then the output should contain exactly: """ will_paginate 1.2.0 (draft) will_paginate 1.2.0-pre (pre-release) will_paginate 1.0.2 ()\n """ Scenario: Repository not found when listing releases Given the GitHub API server: """ get('/repos/mislav/will_paginate/releases') { status 404 json message: "Not Found", documentation_url: "https://developer.github.com/v3" } """ When I run `hub release` Then the stderr should contain exactly: """ Error fetching releases: Not Found (HTTP 404) Not Found\n """ And the exit status should be 1 Scenario: Server error when listing releases Given the GitHub API server: """ get('/repos/mislav/will_paginate/releases') { status 504 'Its fine' } """ When I run `hub release` Then the stderr should contain exactly: """ Error fetching releases: invalid character '<' looking for beginning of value (HTTP 504)\n """ And the exit status should be 1 Scenario: Show specific release Given the GitHub API server: """ get('/repos/mislav/will_paginate/releases') { json [ { tag_name: 'v1.2.0', name: 'will_paginate 1.2.0', draft: true, prerelease: false, tarball_url: "https://github.com/mislav/will_paginate/archive/v1.2.0.tar.gz", zipball_url: "https://github.com/mislav/will_paginate/archive/v1.2.0.zip", assets: [ { browser_download_url: "https://github.com/mislav/will_paginate/releases/download/v1.2.0/example.zip", }, ], body: < true, :tag_name => "v1.2.0", :target_commitish => "", :name => "will_paginate 1.2.0: Instant Gratification Monkey", :body => "" status 201 json :html_url => "https://github.com/mislav/will_paginate/releases/v1.2.0" } """ When I successfully run `hub release create -dm "will_paginate 1.2.0: Instant Gratification Monkey" v1.2.0` Then the output should contain exactly: """ https://github.com/mislav/will_paginate/releases/v1.2.0\n """ Scenario: Create a release from file Given the GitHub API server: """ post('/repos/mislav/will_paginate/releases') { assert :name => "Epic New Version", :body => "body\ngoes\n\nhere" status 201 json :html_url => "https://github.com/mislav/will_paginate/releases/v1.2.0" } """ And a file named "message.txt" with: """ Epic New Version body goes here """ When I successfully run `hub release create -F message.txt v1.2.0` Then the output should contain exactly: """ https://github.com/mislav/will_paginate/releases/v1.2.0\n """ Scenario: Create a release with target commitish Given the GitHub API server: """ post('/repos/mislav/will_paginate/releases') { assert :tag_name => "v1.2.0", :target_commitish => "my-branch" status 201 json :html_url => "https://github.com/mislav/will_paginate/releases/v1.2.0" } """ When I successfully run `hub release create -m hello v1.2.0 -t my-branch` Then the output should contain exactly: """ https://github.com/mislav/will_paginate/releases/v1.2.0\n """ Scenario: Create a release with assets Given the GitHub API server: """ post('/repos/mislav/will_paginate/releases') { status 201 json :html_url => "https://github.com/mislav/will_paginate/releases/v1.2.0", :upload_url => "https://uploads.github.com/uploads/assets{?name,label}" } post('/uploads/assets', :host_name => 'uploads.github.com') { halt 401 unless request.env['HTTP_AUTHORIZATION'] == 'token OTOKEN' assert :name => 'hello-1.2.0.tar.gz', :label => 'Hello World' status 201 } """ And a file named "hello-1.2.0.tar.gz" with: """ TARBALL """ When I successfully run `hub release create -m "hello" v1.2.0 -a "./hello-1.2.0.tar.gz#Hello World"` Then the output should contain exactly: """ https://github.com/mislav/will_paginate/releases/v1.2.0 Attaching 1 asset...\n """ Scenario: Retry attaching assets on 5xx errors Given the GitHub API server: """ attempt = 0 post('/repos/mislav/will_paginate/releases') { status 201 json :html_url => "https://github.com/mislav/will_paginate/releases/v1.2.0", :upload_url => "https://uploads.github.com/uploads/assets{?name,label}" } post('/uploads/assets', :host_name => 'uploads.github.com') { attempt += 1 halt 400 unless request.body.read.to_s == "TARBALL" halt 502 if attempt == 1 status 201 } """ And a file named "hello-1.2.0.tar.gz" with: """ TARBALL """ When I successfully run `hub release create -m "hello" v1.2.0 -a hello-1.2.0.tar.gz` Then the output should contain exactly: """ https://github.com/mislav/will_paginate/releases/v1.2.0 Attaching 1 asset...\n """ Scenario: Create a release with some assets failing Given the GitHub API server: """ post('/repos/mislav/will_paginate/releases') { status 201 json :tag_name => "v1.2.0", :html_url => "https://github.com/mislav/will_paginate/releases/v1.2.0", :upload_url => "https://uploads.github.com/uploads/assets{?name,label}" } post('/uploads/assets', :host_name => 'uploads.github.com') { halt 422 if params[:name] == "two" status 201 } """ And a file named "one" with: """ ONE """ And a file named "two" with: """ TWO """ And a file named "three" with: """ THREE """ When I run `hub release create -m "m" v1.2.0 -a one -a two -a three` Then the exit status should be 1 Then the stderr should contain exactly: """ Attaching 3 assets... The release was created, but attaching 2 assets failed. You can retry with: hub release edit v1.2.0 -m '' -a two -a three Error uploading release asset: Unprocessable Entity (HTTP 422)\n """ Scenario: Create a release with nonexistent asset When I run `hub release create -m "hello" v1.2.0 -a "idontexis.tgz"` Then the exit status should be 1 Then the stderr should contain exactly: """ open idontexis.tgz: no such file or directory\n """ Scenario: Open new release in web browser Given the GitHub API server: """ post('/repos/mislav/will_paginate/releases') { status 201 json :html_url => "https://github.com/mislav/will_paginate/releases/v1.2.0" } """ When I successfully run `hub release create -o -m hello v1.2.0` Then the output should contain exactly "" And "open https://github.com/mislav/will_paginate/releases/v1.2.0" should be run Scenario: Create release no tag When I run `hub release create -m hello` Then the exit status should be 1 Then the stderr should contain "hub release create" Scenario: Edit existing release Given the GitHub API server: """ get('/repos/mislav/will_paginate/releases') { json [ { url: 'https://api.github.com/repos/mislav/will_paginate/releases/123', tag_name: 'v1.2.0', name: 'will_paginate 1.2.0', draft: true, prerelease: false, body: < 'KITTENS EVERYWHERE', :draft => false, :prerelease => nil json({}) } """ Given the git commit editor is "vim" And the text editor adds: """ KITTENS EVERYWHERE """ When I successfully run `hub release edit --draft=false v1.2.0` Then the output should not contain anything Scenario: Edit existing release when there is a fork Given the "doge" remote has url "git://github.com/doge/will_paginate.git" And I am on the "feature" branch with upstream "doge/feature" Given the GitHub API server: """ get('/repos/mislav/will_paginate/releases') { json [ { url: 'https://api.github.com/repos/mislav/will_paginate/releases/123', tag_name: 'v1.2.0', }, ] } patch('/repos/mislav/will_paginate/releases/123') { json({}) } """ When I successfully run `hub release edit -m "" v1.2.0` Then the output should not contain anything Scenario: Edit existing release no title Given the GitHub API server: """ get('/repos/mislav/will_paginate/releases') { json [ { tag_name: 'v1.2.0', name: 'will_paginate 1.2.0', }, ] } """ And a file named "message.txt" with: """ """ When I run `hub release edit v1.2.0 -F message.txt` Then the exit status should be 1 And the stderr should contain exactly: """ Aborting editing due to empty release title\n """ Scenario: Edit existing release by uploading assets Given the GitHub API server: """ deleted = false get('/repos/mislav/will_paginate/releases') { json [ { url: 'https://api.github.com/repos/mislav/will_paginate/releases/123', upload_url: 'https://uploads.github.com/uploads/assets{?name,label}', tag_name: 'v1.2.0', name: 'will_paginate 1.2.0', draft: true, prerelease: false, assets: [ { url: 'https://api.github.com/repos/mislav/will_paginate/assets/456', name: 'hello-1.2.0.tar.gz', }, ], }, ] } delete('/repos/mislav/will_paginate/assets/456') { deleted = true status 204 } post('/uploads/assets', :host_name => 'uploads.github.com') { halt 422 unless deleted halt 401 unless request.env['HTTP_AUTHORIZATION'] == 'token OTOKEN' assert :name => 'hello-1.2.0.tar.gz', :label => nil status 201 } """ And a file named "hello-1.2.0.tar.gz" with: """ TARBALL """ When I successfully run `hub release edit -m "" v1.2.0 -a hello-1.2.0.tar.gz` Then the output should contain exactly: """ Attaching 1 asset...\n """ Scenario: Edit release no tag When I run `hub release edit -m hello` Then the exit status should be 1 Then the stderr should contain "hub release edit" Scenario: Download a release asset Given the GitHub API server: """ get('/repos/mislav/will_paginate/releases') { json [ { tag_name: 'v1.2.0', assets: [ { url: 'https://api.github.com/repos/mislav/will_paginate/assets/9876', name: 'hello-1.2.0.tar.gz', }, ], }, ] } get('/repos/mislav/will_paginate/assets/9876') { halt 401 unless request.env['HTTP_AUTHORIZATION'] == 'token OTOKEN' halt 415 unless request.accept?('application/octet-stream') status 302 headers['Location'] = 'https://github-cloud.s3.amazonaws.com/releases/12204602/22ea221a-cf2f-11e2-222a-b3a3c3b3aa3a.gz' "" } get('/releases/12204602/22ea221a-cf2f-11e2-222a-b3a3c3b3aa3a.gz', :host_name => 'github-cloud.s3.amazonaws.com') { halt 400 unless request.env['HTTP_AUTHORIZATION'].nil? halt 415 unless request.accept?('application/octet-stream') headers['Content-Type'] = 'application/octet-stream' "ASSET_TARBALL" } """ When I successfully run `hub release download v1.2.0` Then the output should contain exactly: """ Downloading hello-1.2.0.tar.gz ...\n """ And the file "hello-1.2.0.tar.gz" should contain exactly: """ ASSET_TARBALL """ Scenario: Download release assets that match pattern Given the GitHub API server: """ get('/repos/mislav/will_paginate/releases') { json [ { tag_name: 'v1.2.0', assets: [ { url: 'https://api.github.com/repos/mislav/will_paginate/assets/9876', name: 'hello-amd32-1.2.0.tar.gz', }, { url: 'https://api.github.com/repos/mislav/will_paginate/assets/9877', name: 'hello-amd64-1.2.0.tar.gz', }, { url: 'https://api.github.com/repos/mislav/will_paginate/assets/9878', name: 'hello-x86-1.2.0.tar.gz', }, ], }, ] } get('/repos/mislav/will_paginate/assets/9876') { "TARBALL" } get('/repos/mislav/will_paginate/assets/9877') { "TARBALL" } """ When I successfully run `hub release download v1.2.0 --include '*amd*'` Then the output should contain exactly: """ Downloading hello-amd32-1.2.0.tar.gz ... Downloading hello-amd64-1.2.0.tar.gz ...\n """ And the file "hello-x86-1.2.0.tar.gz" should not exist Scenario: Glob pattern allows exact match Given the GitHub API server: """ get('/repos/mislav/will_paginate/releases') { json [ { tag_name: 'v1.2.0', assets: [ { url: 'https://api.github.com/repos/mislav/will_paginate/assets/9876', name: 'hello-amd32-1.2.0.tar.gz', }, { url: 'https://api.github.com/repos/mislav/will_paginate/assets/9877', name: 'hello-amd64-1.2.0.tar.gz', }, { url: 'https://api.github.com/repos/mislav/will_paginate/assets/9878', name: 'hello-x86-1.2.0.tar.gz', }, ], }, ] } get('/repos/mislav/will_paginate/assets/9876') { "ASSET_TARBALL" } """ When I successfully run `hub release download v1.2.0 --include hello-amd32-1.2.0.tar.gz` Then the output should contain exactly: """ Downloading hello-amd32-1.2.0.tar.gz ...\n """ And the file "hello-amd32-1.2.0.tar.gz" should contain exactly: """ ASSET_TARBALL """ And the file "hello-amd64-1.2.0.tar.gz" should not exist And the file "hello-x86-1.2.0.tar.gz" should not exist Scenario: Advanced glob pattern Given the GitHub API server: """ get('/repos/mislav/will_paginate/releases') { json [ { tag_name: 'v1.2.0', assets: [ { url: 'https://api.github.com/repos/mislav/will_paginate/assets/9876', name: 'hello-amd32-1.2.0.tar.gz', }, { url: 'https://api.github.com/repos/mislav/will_paginate/assets/9876', name: 'hello-amd32-1.2.1.tar.gz', }, { url: 'https://api.github.com/repos/mislav/will_paginate/assets/9876', name: 'hello-amd32-1.2.2.tar.gz', }, ], }, ] } get('/repos/mislav/will_paginate/assets/9876') { "ASSET_TARBALL" } """ When I successfully run `hub release download v1.2.0 --include '*-amd32-?.?.[01].tar.gz'` Then the output should contain exactly: """ Downloading hello-amd32-1.2.0.tar.gz ... Downloading hello-amd32-1.2.1.tar.gz ...\n """ Scenario: No matches for download pattern Given the GitHub API server: """ get('/repos/mislav/will_paginate/releases') { json [ { tag_name: 'v1.2.0', assets: [ { url: 'https://api.github.com/repos/mislav/will_paginate/assets/9876', name: 'hello-amd32-1.2.0.tar.gz', }, { url: 'https://api.github.com/repos/mislav/will_paginate/assets/9876', name: 'hello-amd32-1.2.1.tar.gz', }, { url: 'https://api.github.com/repos/mislav/will_paginate/assets/9876', name: 'hello-amd32-1.2.2.tar.gz', }, ], }, ] } """ When I run `hub release download v1.2.0 --include amd32` Then the exit status should be 1 Then the stderr should contain exactly: """ the `--include` pattern did not match any available assets: hello-amd32-1.2.0.tar.gz hello-amd32-1.2.1.tar.gz hello-amd32-1.2.2.tar.gz\n """ Scenario: Download release no tag When I run `hub release download` Then the exit status should be 1 Then the stderr should contain "hub release download" Scenario: Delete a release Given the GitHub API server: """ get('/repos/mislav/will_paginate/releases') { json [ { url: 'https://api.github.com/repos/mislav/will_paginate/releases/123', tag_name: 'v1.2.0', }, ] } delete('/repos/mislav/will_paginate/releases/123') { status 204 } """ When I successfully run `hub release delete v1.2.0` Then the output should not contain anything Scenario: Release not found Given the GitHub API server: """ get('/repos/mislav/will_paginate/releases') { assert :per_page => "100" json [ { url: 'https://api.github.com/repos/mislav/will_paginate/releases/123', tag_name: 'v1.2.0', }, ] } delete('/repos/mislav/will_paginate/releases/123') { status 204 } """ When I run `hub release delete v2.0` Then the exit status should be 1 And the stderr should contain exactly: """ Unable to find release with tag name `v2.0'\n """ Scenario: Enterprise list releases Given the "origin" remote has url "git@git.my.org:mislav/will_paginate.git" And I am "mislav" on git.my.org with OAuth token "FITOKEN" And "git.my.org" is a whitelisted Enterprise host Given the GitHub API server: """ get('/api/v3/repos/mislav/will_paginate/releases', :host_name => 'git.my.org') { json [ { tag_name: 'v1.2.0', name: 'will_paginate 1.2.0', draft: false, prerelease: false, }, ] } """ When I successfully run `hub release` Then the output should contain exactly: """ v1.2.0\n """ ================================================ FILE: features/remote_add.feature ================================================ Feature: hub remote add Background: Given I am "EvilChelu" on GitHub.com And I am in "dotfiles" git repo Scenario: Add origin remote for my own repo Given there are no remotes When I successfully run `hub remote add origin` Then the url for "origin" should be "https://github.com/EvilChelu/dotfiles.git" And the output should not contain anything Scenario: Add origin remote for my own repo using -C Given there are no remotes And I cd to ".." When I successfully run `hub -C dotfiles remote add origin` And I cd to "dotfiles" Then the url for "origin" should be "https://github.com/EvilChelu/dotfiles.git" And the output should not contain anything Scenario: Unchanged public remote add When I successfully run `hub remote add origin http://github.com/defunkt/resque.git` Then the url for "origin" should be "http://github.com/defunkt/resque.git" And the output should not contain anything Scenario: Unchanged private remote add When I successfully run `hub remote add origin git@github.com:defunkt/resque.git` Then the url for "origin" should be "git@github.com:defunkt/resque.git" And the output should not contain anything Scenario: Unchanged local path remote add When I successfully run `hub remote add myremote ./path` Then the git command should be unchanged And the output should not contain anything Scenario: Unchanged local absolute path remote add When I successfully run `hub remote add myremote /path` Then the git command should be unchanged And the output should not contain anything Scenario: Unchanged remote add with host alias When I successfully run `hub remote add myremote server:/git/repo.git` Then the git command should be unchanged And the output should not contain anything Scenario: Add new remote for Enterprise repo Given "git.my.org" is a whitelisted Enterprise host And git protocol is preferred And I am "ProLoser" on git.my.org with OAuth token "FITOKEN" And the "origin" remote has url "git@git.my.org:mislav/topsekrit.git" When I successfully run `hub remote add another` Then the url for "another" should be "git@git.my.org:another/topsekrit.git" And the output should not contain anything Scenario: Add public remote Given the GitHub API server: """ get('/repos/mislav/dotfiles') { json :private => false, :name => 'dotfiles', :owner => { :login => 'mislav' }, :permissions => { :push => false } } """ When I successfully run `hub remote add mislav` Then the url for "mislav" should be "https://github.com/mislav/dotfiles.git" And the output should not contain anything Scenario: Add detected private remote Given the GitHub API server: """ get('/repos/mislav/dotfiles') { json :private => true, :name => 'dotfiles', :owner => { :login => 'mislav' }, :permissions => { :push => false } } """ And git protocol is preferred When I successfully run `hub remote add mislav` Then the url for "mislav" should be "git@github.com:mislav/dotfiles.git" And the output should not contain anything Scenario: Add remote with push access Given the GitHub API server: """ get('/repos/mislav/dotfiles') { json :private => false, :name => 'dotfiles', :owner => { :login => 'mislav' }, :permissions => { :push => true } } """ When I successfully run `hub remote add mislav` Then the url for "mislav" should be "https://github.com/mislav/dotfiles.git" And the output should not contain anything Scenario: Add remote for missing repo Given the GitHub API server: """ get('/repos/mislav/dotfiles') { status 404 } """ When I run `hub remote add mislav` Then the exit status should be 1 And the output should contain exactly: """ Error: repository mislav/dotfiles doesn't exist\n """ Scenario: Add explicitly private remote Given git protocol is preferred When I successfully run `hub remote add -p mislav` Then the url for "mislav" should be "git@github.com:mislav/dotfiles.git" And the output should not contain anything Scenario: Remote for my own repo is automatically private Given git protocol is preferred When I successfully run `hub remote add evilchelu` Then the url for "evilchelu" should be "git@github.com:EvilChelu/dotfiles.git" And the output should not contain anything Scenario: Add remote with arguments Given the GitHub API server: """ get('/repos/mislav/dotfiles') { json :private => false, :name => 'dotfiles', :owner => { :login => 'mislav' }, :permissions => { :push => false } } """ When I successfully run `hub remote add -f mislav` Then "git remote add -f mislav https://github.com/mislav/dotfiles.git" should be run And the output should not contain anything Scenario: Add remote with branch argument Given the GitHub API server: """ get('/repos/mislav/dotfiles') { json :private => false, :name => 'dotfiles', :owner => { :login => 'mislav' }, :permissions => { :push => false } } """ When I successfully run `hub remote add -f -t feature mislav` Then "git remote add -f -t feature mislav https://github.com/mislav/dotfiles.git" should be run And the output should not contain anything Scenario: Add named public remote Given the GitHub API server: """ get('/repos/mislav/dotfiles') { json :private => false, :name => 'dotfiles', :owner => { :login => 'mislav' }, :permissions => { :push => false } } """ When I successfully run `hub remote add mm mislav` Then the url for "mm" should be "https://github.com/mislav/dotfiles.git" And the output should not contain anything Scenario: set-url Given the GitHub API server: """ get('/repos/mislav/dotfiles') { json :private => false, :name => 'dotfiles', :owner => { :login => 'mislav' }, :permissions => { :push => false } } """ Given the "origin" remote has url "https://github.com/evilchelu/dotfiles.git" When I successfully run `hub remote set-url origin mislav` Then the url for "origin" should be "https://github.com/mislav/dotfiles.git" And the output should not contain anything Scenario: Add public remote including repo name Given the GitHub API server: """ get('/repos/mislav/dotfilez.js') { json :private => false, :name => 'dotfiles', :owner => { :login => 'mislav' }, :permissions => { :push => false } } """ When I successfully run `hub remote add mislav/dotfilez.js` Then the url for "mislav" should be "https://github.com/mislav/dotfilez.js.git" And the output should not contain anything Scenario: Add named public remote including repo name Given the GitHub API server: """ get('/repos/mislav/dotfilez.js') { json :private => false, :name => 'dotfiles', :owner => { :login => 'mislav' }, :permissions => { :push => false } } """ When I successfully run `hub remote add mm mislav/dotfilez.js` Then the url for "mm" should be "https://github.com/mislav/dotfilez.js.git" And the output should not contain anything Scenario: Add named private remote Given git protocol is preferred When I successfully run `hub remote add -p mm mislav` Then the url for "mm" should be "git@github.com:mislav/dotfiles.git" And the output should not contain anything Scenario: Add private remote including repo name When I successfully run `hub remote add -p mislav/dotfilez.js` Then the url for "mislav" should be "https://github.com/mislav/dotfilez.js.git" And the output should not contain anything Scenario: Add named private remote including repo name When I successfully run `hub remote add -p mm mislav/dotfilez.js` Then the url for "mm" should be "https://github.com/mislav/dotfilez.js.git" And the output should not contain anything Scenario: Add named private remote for my own repo including repo name When I successfully run `hub remote add ec evilchelu/dotfilez.js` Then the url for "ec" should be "https://github.com/EvilChelu/dotfilez.js.git" And the output should not contain anything Scenario: Avoid crash in argument parsing When I successfully run `hub --noop remote add a b evilchelu` Then the output should contain exactly "git remote add a b evilchelu\n" ================================================ FILE: features/steps.rb ================================================ require 'fileutils' Given(/^git protocol is preferred$/) do set_environment_variable "HUB_PROTOCOL", "git" end Given(/^there are no remotes$/) do output = run_ignored_command 'git remote' expect(output).to be_empty end Given(/^"([^"]*)" is a whitelisted Enterprise host$/) do |host| run_ignored_command %(git config --global --add hub.host "#{host}") end Given(/^git "(.+?)" is set to "(.+?)"$/) do |key, value| run_ignored_command %(git config #{key} "#{value}") end Given(/^the "([^"]*)" remote has(?: (push))? url "([^"]*)"$/) do |remote_name, push, url| remotes = run_ignored_command 'git remote' unless remotes.split("\n").include? remote_name run_ignored_command %(git remote add #{remote_name} "#{url}") else run_ignored_command %(git remote set-url #{"--push" if push} #{remote_name} "#{url}") end end Given(/^I am "([^"]*)" on ([\S]+)(?: with OAuth token "([^"]*)")?$/) do |name, host, token| edit_hub_config do |cfg| entry = {'user' => name} host = host.sub(%r{^([\w-]+)://}, '') entry['oauth_token'] = token if token entry['protocol'] = $1 if $1 cfg[host.downcase] = [entry] end end Given(/^\$(\w+) is "([^"]*)"$/) do |name, value| expanded_value = value.gsub(/\$([A-Z_]+)/) { aruba.environment[$1] } set_environment_variable(name, expanded_value) end Given(/^I am in "([^"]*)" git repo$/) do |dir_name| if dir_name.include?(':') origin_url = dir_name dir_name = File.basename origin_url, '.git' end step %(a git repo in "#{dir_name}") step %(I cd to "#{dir_name}") step %(the "origin" remote has url "#{origin_url}") if origin_url end Given(/^a (bare )?git repo in "([^"]*)"$/) do |bare, dir_name| run_ignored_command %(git -c init.defaultBranch=master init --quiet #{"--bare" if bare} '#{dir_name}') end Given(/^a git bundle named "([^"]*)"$/) do |file| dest = expand_path(file) FileUtils.mkdir_p(File.dirname(dest)) Dir.mktmpdir do |tmpdir| Dir.chdir(tmpdir) do `git -c init.defaultBranch=master init --quiet` `GIT_COMMITTER_NAME=a GIT_COMMITTER_EMAIL=b git commit --quiet -m 'empty' --allow-empty --author='a '` `git bundle create "#{dest}" master 2>&1` end end end Given(/^there is a commit named "([^"]+)"$/) do |name| empty_commit empty_commit run_ignored_command %(git tag #{name}) run_ignored_command %(git reset --quiet --hard HEAD^) end Given(/^there is a git FETCH_HEAD$/) do empty_commit empty_commit cd('.') do File.open(".git/FETCH_HEAD", "w") do |fetch_head| fetch_head.puts "%s\t\t'refs/heads/made-up' of git://github.com/made/up.git" % `git rev-parse HEAD`.chomp end end run_ignored_command %(git reset --quiet --hard HEAD^) end When(/^I make (a|\d+) commits?(?: with message "([^"]+)")?$/) do |num, msg| num = num == 'a' ? 1 : num.to_i num.times { empty_commit(msg) } end When(/^I make a commit with message:$/) do |msg| empty_commit(msg) end Then(/^the latest commit message should be "([^"]+)"$/) do |subject| step %(I successfully run `git log -1 --format=%s`) step %(the output should contain exactly "#{subject}\\n") end # expand `<$HOME>` etc. in matched text Then(/^(the (?:output|stderr|stdout)) with expanded variables( should contain(?: exactly)?:)/) do |prefix, postfix, text| step %(#{prefix}#{postfix}), text.gsub(/<\$(\w+)>/) { aruba.environment[$1] } end Given(/^the "([^"]+)" branch is pushed to "([^"]+)"$/) do |name, upstream| full_upstream = ".git/refs/remotes/#{upstream}" cd('.') do FileUtils.mkdir_p File.dirname(full_upstream) FileUtils.cp ".git/refs/heads/#{name}", full_upstream end end Given(/^I am on the "([^"]+)" branch(?: (pushed to|with upstream) "([^"]+)")?$/) do |name, type, upstream| run_ignored_command %(git checkout --quiet -b #{shell_escape name}) empty_commit if upstream full_upstream = upstream.start_with?('refs/') ? upstream : "refs/remotes/#{upstream}" run_ignored_command %(git update-ref #{shell_escape full_upstream} HEAD) if type == 'with upstream' run_ignored_command %(git branch --set-upstream-to #{shell_escape upstream}) end end end Given(/^the default branch for "([^"]+)" is "([^"]+)"$/) do |remote, branch| cd('.') do ref_file = ".git/refs/remotes/#{remote}/#{branch}" unless File.exist? ref_file empty_commit unless File.exist? '.git/refs/heads/master' FileUtils.mkdir_p File.dirname(ref_file) FileUtils.cp '.git/refs/heads/master', ref_file end end run_ignored_command %(git remote set-head #{remote} #{branch}) end Given(/^I am in detached HEAD$/) do empty_commit empty_commit run_ignored_command %(git checkout HEAD^) end Given(/^the current dir is not a repo$/) do FileUtils.rm_rf(expand_path('.git')) end Given(/^the GitHub API server:$/) do |endpoints_str| @server = Hub::LocalServer.start_sinatra do eval endpoints_str, binding end # hit our Sinatra server instead of github.com set_environment_variable 'HUB_TEST_HOST', "http://127.0.0.1:#{@server.port}" end Then(/^shell$/) do cd('.') do system '/bin/bash -i' end end Then(/^"([^"]*)" should be run$/) do |cmd| assert_command_run cmd end Then(/^it should clone "([^"]*)"$/) do |repo| step %("git clone #{repo}" should be run) end Then(/^it should not clone anything$/) do history.each { |h| expect(h).to_not match(/^git clone/) } end Then(/^"([^"]+)" should not be run$/) do |pattern| history.each { |h| expect(h).to_not include(pattern) } end Then(/^the git command should be unchanged$/) do expect(@commands).to_not be_empty assert_command_run @commands.last.sub(/^hub\b/, 'git') end Then(/^the url for "([^"]*)" should be "([^"]*)"$/) do |name, url| output = run_ignored_command %(git config --get-all remote.#{name}.url) expect(output).to include(url) end Then(/^the "([^"]*)" submodule url should be "([^"]*)"$/) do |name, url| output = run_ignored_command %(git config --get-all submodule."#{name}".url) expect(output).to include(url) end Then(/^"([^"]*)" should merge "([^"]*)" from remote "([^"]*)"$/) do |name, merge, remote| output = run_ignored_command %(git config --get-all branch.#{name}.remote) expect(output).to include(remote) output = run_ignored_command %(git config --get-all branch.#{name}.merge) expect(output).to include(merge) end Then(/^there should be no "([^"]*)" remote$/) do |remote_name| remotes = run_ignored_command 'git remote' expect(remotes.split("\n")).to_not include(remote_name) end Then(/^the file "([^"]*)" should have mode "([^"]*)"$/) do |file, expected_mode| mode = File.stat(expand_path(file)).mode expect(mode.to_s(8)).to match(/#{expected_mode}$/) end Given(/^the remote commit states of "(.*?)" "(.*?)" are:$/) do |proj, ref, json_value| if ref == 'HEAD' empty_commit end output = run_ignored_command %(git rev-parse #{ref}) rev = output.chomp host, owner, repo = proj.split('/', 3) if repo.nil? repo = owner owner = host host = nil end status_endpoint = <<-EOS get('#{'/api/v3' if host}/repos/#{owner}/#{repo}/commits/#{rev}/status'#{", :host_name => '#{host}'" if host}) { json(#{json_value}) } get('#{'/api/v3' if host}/repos/#{owner}/#{repo}/commits/#{rev}/check-runs'#{", :host_name => '#{host}'" if host}) { status 422 } EOS step %{the GitHub API server:}, status_endpoint end Given(/^the remote commit state of "(.*?)" "(.*?)" is "(.*?)"$/) do |proj, ref, status| step %{the remote commit states of "#{proj}" "#{ref}" are:}, <<-EOS { :state => "#{status}", :statuses => [ { :state => "#{status}", :context => "continuous-integration/travis-ci/push", :target_url => 'https://travis-ci.org/#{proj}/builds/1234567' } ] } EOS end Given(/^the remote commit state of "(.*?)" "(.*?)" is nil$/) do |proj, ref| step %{the remote commit states of "#{proj}" "#{ref}" are:}, %({ :state => "pending", :statuses => [] }) end Given(/^the text editor exits with error status$/) do text_editor_script "exit 1" end Given(/^the text editor adds:$/) do |text| text_editor_script <<-BASH file="$3" contents="$(cat "$file" 2>/dev/null || true)" { echo "#{text}" echo echo "$contents" } > "$file" BASH end When(/^I pass in:$/) do |input| type(input) close_input end Given(/^the git commit editor is "([^"]+)"$/) do |cmd| set_environment_variable('GIT_EDITOR', cmd) end Given(/^the SSH config:$/) do |config_lines| ssh_config = expand_path('~/.ssh/config') FileUtils.mkdir_p(File.dirname(ssh_config)) File.open(ssh_config, 'w') {|f| f << config_lines } end Given(/^the SHAs and timestamps are normalized in "([^"]+)"$/) do |file| file = expand_path(file) contents = File.read(file) contents.gsub!(/[0-9a-f]{7} \(Hub, \d seconds? ago\)/, "SHA1SHA (Hub, 0 seconds ago)") File.open(file, "w") { |f| f.write(contents) } end Then(/^its (output|stderr|stdout) should( not)? contain( exactly)?:$/) do |channel, negated, exactly, expected| matcher = case channel.to_sym when :output :have_output when :stderr :have_output_on_stderr when :stdout :have_output_on_stdout end commands = [last_command_started] output_string_matcher = if exactly :an_output_string_being_eq else :an_output_string_including end if negated expect(commands).not_to include_an_object send(matcher, send(output_string_matcher, expected)) else expect(commands).to include_an_object send(matcher, send(output_string_matcher, expected)) end end ================================================ FILE: features/submodule_add.feature ================================================ Feature: hub submodule add Background: Given I am "mislav" on github.com with OAuth token "OTOKEN" Given I am in "dotfiles" git repo # make existing repo in subdirectory so git clone isn't triggered Given a git repo in "vendor/grit" And I cd to "vendor/grit" And I make 1 commit And I cd to "../.." Scenario: Add public submodule Given the GitHub API server: """ get('/repos/mojombo/grit') { json :private => false, :name => 'grit', :owner => { :login => 'mojombo' }, :permissions => { :push => false } } """ When I successfully run `hub submodule add mojombo/grit vendor/grit` Then the "vendor/grit" submodule url should be "https://github.com/mojombo/grit.git" And the output should contain exactly: """ Adding existing repo at 'vendor/grit' to the index\n """ Scenario: Add private submodule Given the GitHub API server: """ get('/repos/mojombo/grit') { json :private => false, :name => 'grit', :owner => { :login => 'mojombo' }, :permissions => { :push => false } } """ When I successfully run `hub submodule add -p mojombo/grit vendor/grit` Then the "vendor/grit" submodule url should be "https://github.com/mojombo/grit.git" Scenario: A submodule for my own repo is public nevertheless Given the GitHub API server: """ get('/repos/mislav/grit') { json :private => false, :name => 'grit', :owner => { :login => 'mislav' }, :permissions => { :push => true } } """ When I successfully run `hub submodule add grit vendor/grit` Then the "vendor/grit" submodule url should be "https://github.com/mislav/grit.git" Scenario: Add submodule with arguments Given the GitHub API server: """ get('/repos/mojombo/grit') { json :private => false, :name => 'grit', :owner => { :login => 'mojombo' }, :permissions => { :push => false } } """ When I successfully run `hub submodule add -b foo --name grit mojombo/grit vendor/grit` Then "git submodule add -b foo --name grit https://github.com/mojombo/grit.git vendor/grit" should be run Scenario: Add submodule with branch Given the GitHub API server: """ get('/repos/mojombo/grit') { json :private => false, :name => 'grit', :owner => { :login => 'mojombo' }, :permissions => { :push => false } } """ When I successfully run `hub submodule add --branch foo mojombo/grit vendor/grit` Then "git submodule add --branch foo https://github.com/mojombo/grit.git vendor/grit" should be run ================================================ FILE: features/support/completion.rb ================================================ # Driver for completion tests executed via a separate tmux pane in which we # spawn an interactive shell, send keystrokes to and inspect the outcome of # tab-completion. # # Prerequisites: # - tmux # - bash # - zsh # - fish # - git require 'fileutils' require 'rspec/expectations' require 'pathname' tmpdir = Pathname.new(ENV.fetch('TMPDIR', '/tmp')) + 'hub-test' cpldir = tmpdir + 'completion' zsh_completion = File.expand_path('../../../etc/hub.zsh_completion', __FILE__) bash_completion = File.expand_path('../../../etc/hub.bash_completion.sh', __FILE__) fish_completion = File.expand_path('../../../etc/hub.fish_completion', __FILE__) _git_prefix = nil git_prefix = lambda { _git_prefix ||= begin git_core = Pathname.new(`git --exec-path`.chomp) git_core.dirname.dirname end } git_distributed_zsh_completion = lambda { [ git_prefix.call + 'contrib/completion/git-completion.zsh', git_prefix.call + 'share/git-core/contrib/completion/git-completion.zsh', git_prefix.call + 'share/zsh/site-functions/_git', ].detect {|p| p.exist? } } git_distributed_bash_completion = lambda { [ git_prefix.call + 'contrib/completion/git-completion.bash', git_prefix.call + 'share/git-core/contrib/completion/git-completion.bash', git_prefix.call + 'share/git-core/git-completion.bash', git_prefix.call + 'etc/bash_completion.d/git-completion.bash', Pathname.new('/etc/bash_completion.d/git'), Pathname.new('/usr/share/bash-completion/completions/git'), Pathname.new('/usr/share/bash-completion/git'), ].detect {|p| p.exist? } } link_completion = Proc.new { |from, name| raise ArgumentError, from.to_s unless File.exist?(from) FileUtils.mkdir_p(cpldir) FileUtils.ln_s(from, cpldir + name, :force => true) } create_file = lambda { |name, &block| FileUtils.mkdir_p(File.dirname(name)) File.open(name, 'w', &block) } setup_tmp_home = lambda { |shell| FileUtils.rm_rf(tmpdir) case shell when 'zsh' create_file.call(tmpdir + '.zshrc') do |zshrc| zshrc.write <<-SH PS1='$ ' for site_fn in /usr/{local/,}share/zsh/site-functions; do fpath=(${fpath#\$site_fn}) done fpath=('#{cpldir}' $fpath) alias git=hub autoload -U compinit compinit -i SH end when 'bash' create_file.call(tmpdir + '.bashrc') do |bashrc| bashrc.write <<-SH PS1='$ ' alias git=hub . '#{git_distributed_bash_completion.call}' . '#{bash_completion}' SH end when 'fish' create_file.call(tmpdir + '.config/fish/config.fish') do |fishcfg| fishcfg.write <<-SH function fish_prompt echo '$ ' end SH end create_file.call(tmpdir + '.config/fish/functions/git.fish') do |gitfn| gitfn.write <<-SH function git --wraps hub hub $argv end SH end completion_dest = tmpdir + '.config/fish/completions/hub.fish' FileUtils.mkdir_p(File.dirname(completion_dest)) FileUtils.ln_s(fish_completion, completion_dest) end } $tmux = nil $installed_shells = Hash.new { |cache, shell| `which #{shell} 2>/dev/null` cache[shell] = $?.success? } Before('@completion') do unless $tmux $tmux = %w[tmux -L hub-test] system(*($tmux + %w[new-session -ds hub])) at_exit do system(*($tmux + %w[kill-server])) end end end After('@completion') do tmux_kill_pane end World Module.new { attr_reader :shell def set_shell(shell) if $installed_shells[shell] @shell = shell true else false end end define_method(:tmux_pane) do return @tmux_pane if tmux_pane? Dir.chdir(tmpdir) do @tmux_pane = `#{$tmux.join(' ')} new-window -dP -n test 'env HOME="#{tmpdir}" #{shell}'`.chomp end end def tmux_pane? defined?(@tmux_pane) && @tmux_pane end def tmux_pane_contents system(*($tmux + ['capture-pane', '-t', tmux_pane])) `#{$tmux.join(' ')} show-buffer`.rstrip end def tmux_send_keys(*keys) system(*($tmux + ['send-keys', '-t', tmux_pane, *keys])) end def tmux_send_tab @last_pane_contents = tmux_pane_contents tmux_send_keys('Tab') end def tmux_kill_pane system(*($tmux + ['kill-pane', '-t', tmux_pane])) if tmux_pane? end def tmux_wait_for_prompt num_waited = 0 while tmux_pane_contents !~ /\$\Z/ raise "timeout while waiting for shell prompt" if num_waited > 300 sleep 0.01 num_waited += 1 end end def tmux_wait_for_completion num_waited = 0 raise "tmux_send_tab not called first" unless defined? @last_pane_contents while tmux_pane_contents == @last_pane_contents if num_waited > 300 if block_given? then return yield else raise "timeout while waiting for completions to expand" end end sleep 0.01 num_waited += 1 end end def tmux_output_lines tmux_pane_contents.split("\n").drop_while { |l| not l.start_with?('$') }.reject do |line| line.start_with?('$') end end def tmux_completion_menu tmux_wait_for_completion hash = {} if 'fish' == shell tmux_output_lines.each do |line| line.scan(/([^(]+)\((.+?)\)/).each do |flags, description| flags.strip.split(/\s+/).each do |flag| hash[flag] = description end end end else tmux_output_lines.each do |line| item, description = line.split(/ +-- +/, 2) hash[item] = description if description end end hash end def tmux_completion_menu_basic tmux_wait_for_completion if 'fish' == shell tmux_completion_menu.keys else tmux_output_lines.flat_map do |line| line.split(/\s+/) end end end } Given(/^my shell is (\w+)$/) do |shell| set_shell(shell) || pending setup_tmp_home.call(shell) end Given(/^I'm using ((?:zsh|git)-distributed) base git completions$/) do |type| link_completion.call(zsh_completion, '_hub') case type when 'zsh-distributed' raise "this combination makes no sense!" if 'bash' == shell expect((cpldir + '_git')).to_not be_exist when 'git-distributed' if 'zsh' == shell if git_zsh_completion = git_distributed_zsh_completion.call link_completion.call(git_zsh_completion, '_git') link_completion.call(git_distributed_bash_completion.call, 'git-completion.bash') else warn "warning: git-distributed zsh completion wasn't found; using zsh-distributed instead" end end if 'bash' == shell unless git_distributed_bash_completion.call raise "git-distributed bash completion wasn't found. Completion won't work." end end else raise ArgumentError, type end end When(/^I type "(.+?)" and press $/) do |string| tmux_wait_for_prompt @last_command = string tmux_send_keys(string) tmux_send_tab end When(/^I press again$/) do tmux_send_tab end Then(/^the completion menu should offer "([^"]+?)"( unsorted)?$/) do |items, unsorted| menu = tmux_completion_menu_basic if unsorted menu.sort! items = items.split(' ').sort.join(' ') end expect(menu.join(' ')).to eq(items) end Then(/^the completion menu should offer "(.+?)" with description "(.+?)"$/) do |item, description| menu = tmux_completion_menu expect(menu.keys).to include(item) expect(menu[item]).to eq(description) end Then(/^the completion menu should offer:/) do |table| menu = tmux_completion_menu expect(menu).to eq(table.rows_hash) end Then(/^the command should expand to "(.+?)"$/) do |cmd| tmux_wait_for_completion expect(tmux_pane_contents).to match(/^\$ #{cmd}$/) end Then(/^the command should not expand$/) do tmux_wait_for_completion { false } expect(tmux_pane_contents).to match(/^\$ #{@last_command}$/) end ================================================ FILE: features/support/env.rb ================================================ require 'aruba/cucumber' require 'fileutils' require 'forwardable' require 'tmpdir' require 'open3' system_git = `which git 2>/dev/null`.chomp bin_dir = File.expand_path('../fakebin', __FILE__) tmpdir = Dir.mktmpdir('hub_test') tmp_bin_dir = "#{tmpdir}/bin" Aruba.configure do |aruba| aruba.send(:find_option, :root_directory).value = tmpdir end hub_dir = Dir.mktmpdir('hub_build') raise 'hub build failed' unless system("./script/build -o #{hub_dir}/hub") Before do author_name = "Hub" author_email = "hub@test.local" aruba.environment.update( # speed up load time by skipping RubyGems 'RUBYOPT' => '--disable-gems', # put fakebin on the PATH 'PATH' => "#{hub_dir}:#{tmp_bin_dir}:#{bin_dir}:#{ENV['PATH']}", # clear out GIT if it happens to be set 'GIT' => nil, # exclude this project's git directory from use in testing 'GIT_CEILING_DIRECTORIES' => File.expand_path('../../..', __FILE__), # sabotage git commands that might try to access a remote host 'GIT_PROXY_COMMAND' => 'echo', # avoids reading from current user's "~/.gitconfig" 'HOME' => expand_path('home'), 'TMPDIR' => tmpdir, # https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html#variables 'XDG_CONFIG_HOME' => nil, 'XDG_CONFIG_DIRS' => nil, # used in fakebin/git 'HUB_SYSTEM_GIT' => system_git, # ensure that api.github.com is actually never hit in tests 'HUB_TEST_HOST' => 'http://127.0.0.1:0', # ensure we use fakebin `open` to test browsing 'BROWSER' => 'open', # sabotage opening a commit message editor interactively 'GIT_EDITOR' => 'false', # reset current localization settings 'LANG' => nil, 'LANGUAGE' => nil, 'LC_ALL' => 'C.UTF-8', # ignore current user's token 'GITHUB_TOKEN' => nil, 'GITHUB_USER' => nil, 'GITHUB_PASSWORD' => nil, 'GITHUB_HOST' => nil, 'GITHUB_REPOSITORY' => nil, 'GIT_AUTHOR_NAME' => author_name, 'GIT_COMMITTER_NAME' => author_name, 'GIT_AUTHOR_EMAIL' => author_email, 'GIT_COMMITTER_EMAIL' => author_email, 'HUB_VERSION' => 'dev', 'HUB_REPORT_CRASH' => 'never', 'HUB_PROTOCOL' => nil, ) FileUtils.mkdir_p(expand_path('~')) end After do @server.stop if defined? @server and @server FileUtils.rm_f("#{tmp_bin_dir}/vim") end After('@cache_clear') do FileUtils.rm_rf("#{tmpdir}/hub/api") end RSpec::Matchers.define :be_successfully_executed do match do |cmd| expect(cmd).to have_exit_status(0) end failure_message do |cmd| msg = %(command `#{cmd.commandline}` exited with status #{cmd.exit_status}) stderr = cmd.stderr msg << ":\n" << stderr.gsub(/^/, ' ') unless stderr.empty? msg end end World Module.new { # If there are multiple inputs, e.g., type in username and then type in password etc., # the Go program will freeze on the second input. Giving it a small time interval # temporarily solves the problem. # See https://github.com/cucumber/aruba/blob/7afbc5c0cbae9c9a946d70c4c2735ccb86e00f08/lib/aruba/api.rb#L379-L382 def type(*args) super.tap { sleep 0.1 } end def history histfile = expand_path('~/.history') if File.exist? histfile File.readlines histfile else [] end end def assert_command_run cmd cmd += "\n" unless cmd[-1..-1] == "\n" expect(history).to include(cmd) end def edit_hub_config config = expand_path('~/.config/hub') FileUtils.mkdir_p File.dirname(config) if File.exist? config data = YAML.load File.read(config) else data = {} end yield data File.open(config, 'w') { |cfg| cfg << YAML.dump(data) } end define_method(:text_editor_script) do |bash_code| FileUtils.mkdir_p(tmp_bin_dir) File.open("#{tmp_bin_dir}/vim", 'w', 0755) { |exe| exe.puts "#!/bin/bash" exe.puts "set -e" exe.puts bash_code } end def empty_commit(message = nil) unless message @empty_commit_count = defined?(@empty_commit_count) ? @empty_commit_count + 1 : 1 message = "empty #{@empty_commit_count}" end run_ignored_command "git commit --quiet -m '#{message}' --allow-empty" end def shell_escape(message) message.to_s.gsub(/['"\\ $]/) { |m| "\\#{m}" } end # runs a command entirely outside of Aruba's command system and returns its stdout def run_ignored_command(cmd_string) stdout, stderr, status = Open3.capture3(aruba.environment, cmd_string, chdir: expand_path('.')) expect(status).to be_success stdout end } ================================================ FILE: features/support/fakebin/curl ================================================ #!/bin/bash echo "curl is not allowed" >&2 exit 1 ================================================ FILE: features/support/fakebin/git ================================================ #!/bin/bash # A wrapper for system git that prevents commands such as `clone` or `fetch` to be # executed in testing. It logs commands to "~/.history" so afterwards it can be # asserted that they ran. set -e command="$1" case "$command" in "config" ) ;; "web--browse" ) echo git web--browse PATH/$(basename "$2") >> "$HOME"/.history ;; * ) echo git "$@" >> "$HOME"/.history ;; esac case "$command" in "--list-cmds="* ) echo add echo branch echo commit ;; "fetch" ) [[ $2 != -* && $3 == *:* && $3 != -* ]] || exit 0 refspec="$3" dest="${refspec#*:}" head="$(git rev-parse --verify -q HEAD || true)" if [ -z "$head" ]; then git commit --allow-empty -m "auto-commit" head="$(git rev-parse --verify -q HEAD)" fi if [[ $dest == refs/remotes/* ]]; then mkdir -p ".git/${dest%/*}" cat >".git/${dest}" <<<"$head" cat >".git/FETCH_HEAD" <<<"$head" else "$HUB_SYSTEM_GIT" checkout -b "${dest#refs/heads/}" HEAD cat >".git/FETCH_HEAD" <<<"$head" fi exit 0 ;; "clone" | "pull" | "push" | "web--browse" ) # don't actually execute these commands exit 0 ;; * ) # note: `submodule add` also initiates a clone, but we work around it if [ "$command $2 $3" = "remote add -f" ]; then subcommand=$2 shift 3 exec "$HUB_SYSTEM_GIT" $command $subcommand "$@" else exec "$HUB_SYSTEM_GIT" "$@" fi ;; esac ================================================ FILE: features/support/fakebin/man ================================================ #!/bin/sh echo man "$@" >> "$HOME"/.history ================================================ FILE: features/support/fakebin/open ================================================ #!/bin/sh echo open "$@" >> "$HOME"/.history ================================================ FILE: features/support/local_server.rb ================================================ # based on require 'net/http' require 'rack/handler/webrick' require 'json' require 'sinatra/base' module Hub class LocalServer class Identify < Struct.new(:app) def call(env) if env["PATH_INFO"] == "/__identify__" [200, {}, [app.object_id.to_s]] else app.call(env) end end end def self.ports @ports ||= {} end class JsonParamsParser < Struct.new(:app) def call(env) if env['rack.input'] and not input_parsed?(env) and type_match?(env) env['rack.request.form_input'] = env['rack.input'] data = env['rack.input'].read env['rack.request.form_hash'] = data.empty?? {} : JSON.parse(data) end app.call(env) end def input_parsed? env env['rack.request.form_input'].eql? env['rack.input'] end def type_match? env type = env['CONTENT_TYPE'] and type.split(/\s*[;,]\s*/, 2).first.downcase =~ /[\/+]json$/ end end class App < Sinatra::Base def invoke res = super content_type :json unless response.content_type response.body = '{}' if blank_response?(response.body) || (response.body.respond_to?(:[]) && blank_response?(response.body[0])) res end def blank_response?(obj) obj.nil? || (obj.respond_to?(:empty?) && obj.empty?) end end def self.start_sinatra(&block) klass = Class.new(App) klass.use JsonParamsParser klass.set :environment, :test klass.disable :protection klass.error(404, 401) { content_type :json; nil } klass.class_eval(&block) klass.helpers do def json(value) content_type :json JSON.generate value end def assert(expected, data = params) expected.each do |key, value| if :no == value halt 422, json( :message => "did not expect any value for %p; got %p" % [key, data[key]] ) if data.key?(key.to_s) elsif Regexp === value halt 422, json( :message => "expected %p to match %p; got %p" % [key, value, data[key] ] ) unless value =~ data[key] elsif Hash === value assert(value, data[key]) elsif data[key] != value halt 422, json( :message => "expected %p to be %p; got %p" % [key, value, data[key]] ) end end end def assert_basic_auth(*expected) require 'rack/auth/basic' auth = Rack::Auth::Basic::Request.new(env) if auth.credentials != expected halt 401, json(:message => "Bad credentials") end end def generate_patch(subject) < Date: Tue, 24 Jun 2014 11:07:05 -0700 Subject: [PATCH] #{subject} --- diff --git a/README.md b/README.md new file mode 100644 index 0000000..ce01362 --- /dev/null +++ b/README.md +hello -- 1.9.3 PATCH end end new(klass.new).start end attr_reader :app, :host, :port attr_accessor :server def initialize(app, host = '127.0.0.1') @app = app @host = host @server = nil @server_thread = nil end def responsive? return false if @server_thread && @server_thread.join(0) res = Net::HTTP.start(host, port) { |http| http.get('/__identify__') } res.is_a?(Net::HTTPSuccess) and res.body == app.object_id.to_s rescue Errno::ECONNREFUSED, Errno::EBADF return false end def start @port = self.class.ports[app.object_id] if not @port or not responsive? tries = 0 begin @server_thread = start_handler(Identify.new(app)) do |server, host, port| self.server = server @port = self.class.ports[app.object_id] = port end Timeout.timeout(5) { @server_thread.join(0.01) until responsive? } rescue Timeout::Error tries += 1 retry if tries < 3 raise "Rack application timed out during boot after #{tries} tries" end end self end def start_handler(app) server = nil thread = Rack::Handler::WEBrick.run(app, server_options) { |s| server = s } addr = server.listeners[0].addr yield server, addr[3], addr[1] return thread end def server_options { :Port => 0, :BindAddress => '127.0.0.1', :ShutdownSocketWithoutClose => true, :ServerType => Thread, :AccessLog => [], :Logger => WEBrick::Log::new(nil, 0) } end def stop server.shutdown @server_thread.join end end end WEBrick::HTTPStatus::StatusMessage[422] = "Unprocessable Entity" ================================================ FILE: features/support/rspec_matchers.rb ================================================ # Avoids over-zealous sanitize_text # https://github.com/cucumber/aruba/blob/v1.0.4/lib/aruba/matchers/string/output_string_eq.rb sanitize_text = ->(expected) { expected.to_s. # convert "\n" in expectations to literal newline, unless it is preceded by another backslash gsub(/(? Then the completion menu should offer "pull-request" with description "open a pull request on GitHub" When I press again Then the command should expand to "git pull" When I press again Then the command should expand to "git pull-request" Scenario: "ci-" expands to "ci-status" When I type "git ci-" and press Then the command should expand to "git ci-status" Scenario: Completion of pull-request arguments When I type "git pull-request -" and press Then the completion menu should offer: | -b | base | | -h | head | | -m | message | | -F | file | | -i | issue | | -f | force (skip check for local commits) | | -a | user | | -M | milestone | | -l | labels | Scenario: Completion of fork arguments When I type "git fork -" and press Then the command should expand to "git fork --no-remote" Scenario: Completion of 2nd browse argument When I type "git browse -- i" and press Then the command should expand to "git browse -- issues" # In this combination, zsh uses completion support from a bash script. Scenario: "ci-" expands to "ci-status" Given I'm using git-distributed base git completions When I type "git ci-" and press Then the command should expand to "git ci-status" ================================================ FILE: fixtures/fixtures.go ================================================ package fixtures import ( "os" "path/filepath" ) func Path(segment ...string) string { pwd, _ := os.Getwd() p := []string{pwd, "..", "fixtures"} p = append(p, segment...) return filepath.Join(p...) } ================================================ FILE: fixtures/release_dir/dir/file2 ================================================ ================================================ FILE: fixtures/release_dir/dir/file3 ================================================ ================================================ FILE: fixtures/release_dir/dir/subdir/file4 ================================================ ================================================ FILE: fixtures/release_dir/file1 ================================================ ================================================ FILE: fixtures/test.git/HEAD ================================================ ref: refs/heads/master ================================================ FILE: fixtures/test.git/config ================================================ [core] repositoryformatversion = 0 filemode = true bare = true ignorecase = true precomposeunicode = false ================================================ FILE: fixtures/test.git/description ================================================ Unnamed repository; edit this file 'description' to name the repository. ================================================ FILE: fixtures/test.git/hooks/applypatch-msg.sample ================================================ #!/bin/sh # # An example hook script to check the commit log message taken by # applypatch from an e-mail message. # # The hook should exit with non-zero status after issuing an # appropriate message if it wants to stop the commit. The hook is # allowed to edit the commit message file. # # To enable this hook, rename this file to "applypatch-msg". . git-sh-setup test -x "$GIT_DIR/hooks/commit-msg" && exec "$GIT_DIR/hooks/commit-msg" ${1+"$@"} : ================================================ FILE: fixtures/test.git/hooks/commit-msg.sample ================================================ #!/bin/sh # # An example hook script to check the commit log message. # Called by "git commit" with one argument, the name of the file # that has the commit message. The hook should exit with non-zero # status after issuing an appropriate message if it wants to stop the # commit. The hook is allowed to edit the commit message file. # # To enable this hook, rename this file to "commit-msg". # Uncomment the below to add a Signed-off-by line to the message. # Doing this in a hook is a bad idea in general, but the prepare-commit-msg # hook is more suited to it. # # SOB=$(git var GIT_AUTHOR_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p') # grep -qs "^$SOB" "$1" || echo "$SOB" >> "$1" # This example catches duplicate Signed-off-by lines. test "" = "$(grep '^Signed-off-by: ' "$1" | sort | uniq -c | sed -e '/^[ ]*1[ ]/d')" || { echo >&2 Duplicate Signed-off-by lines. exit 1 } ================================================ FILE: fixtures/test.git/hooks/post-update.sample ================================================ #!/bin/sh # # An example hook script to prepare a packed repository for use over # dumb transports. # # To enable this hook, rename this file to "post-update". exec git update-server-info ================================================ FILE: fixtures/test.git/hooks/pre-applypatch.sample ================================================ #!/bin/sh # # An example hook script to verify what is about to be committed # by applypatch from an e-mail message. # # The hook should exit with non-zero status after issuing an # appropriate message if it wants to stop the commit. # # To enable this hook, rename this file to "pre-applypatch". . git-sh-setup test -x "$GIT_DIR/hooks/pre-commit" && exec "$GIT_DIR/hooks/pre-commit" ${1+"$@"} : ================================================ FILE: fixtures/test.git/hooks/pre-commit.sample ================================================ #!/bin/sh # # An example hook script to verify what is about to be committed. # Called by "git commit" with no arguments. The hook should # exit with non-zero status after issuing an appropriate message if # it wants to stop the commit. # # To enable this hook, rename this file to "pre-commit". if git rev-parse --verify HEAD >/dev/null 2>&1 then against=HEAD else # Initial commit: diff against an empty tree object against=4b825dc642cb6eb9a060e54bf8d69288fbee4904 fi # If you want to allow non-ascii filenames set this variable to true. allownonascii=$(git config hooks.allownonascii) # Redirect output to stderr. exec 1>&2 # Cross platform projects tend to avoid non-ascii filenames; prevent # them from being added to the repository. We exploit the fact that the # printable range starts at the space character and ends with tilde. if [ "$allownonascii" != "true" ] && # Note that the use of brackets around a tr range is ok here, (it's # even required, for portability to Solaris 10's /usr/bin/tr), since # the square bracket bytes happen to fall in the designated range. test $(git diff --cached --name-only --diff-filter=A -z $against | LC_ALL=C tr -d '[ -~]\0' | wc -c) != 0 then echo "Error: Attempt to add a non-ascii file name." echo echo "This can cause problems if you want to work" echo "with people on other platforms." echo echo "To be portable it is advisable to rename the file ..." echo echo "If you know what you are doing you can disable this" echo "check using:" echo echo " git config hooks.allownonascii true" echo exit 1 fi # If there are whitespace errors, print the offending file names and fail. exec git diff-index --check --cached $against -- ================================================ FILE: fixtures/test.git/hooks/pre-push.sample ================================================ #!/bin/sh # An example hook script to verify what is about to be pushed. Called by "git # push" after it has checked the remote status, but before anything has been # pushed. If this script exits with a non-zero status nothing will be pushed. # # This hook is called with the following parameters: # # $1 -- Name of the remote to which the push is being done # $2 -- URL to which the push is being done # # If pushing without using a named remote those arguments will be equal. # # Information about the commits which are being pushed is supplied as lines to # the standard input in the form: # # # # This sample shows how to prevent push of commits where the log message starts # with "WIP" (work in progress). remote="$1" url="$2" z40=0000000000000000000000000000000000000000 IFS=' ' while read local_ref local_sha remote_ref remote_sha do if [ "$local_sha" = $z40 ] then # Handle delete else if [ "$remote_sha" = $z40 ] then # New branch, examine all commits range="$local_sha" else # Update to existing branch, examine new commits range="$remote_sha..$local_sha" fi # Check for WIP commit commit=`git rev-list -n 1 --grep '^WIP' "$range"` if [ -n "$commit" ] then echo "Found WIP commit in $local_ref, not pushing" exit 1 fi fi done exit 0 ================================================ FILE: fixtures/test.git/hooks/pre-rebase.sample ================================================ #!/bin/sh # # Copyright (c) 2006, 2008 Junio C Hamano # # The "pre-rebase" hook is run just before "git rebase" starts doing # its job, and can prevent the command from running by exiting with # non-zero status. # # The hook is called with the following parameters: # # $1 -- the upstream the series was forked from. # $2 -- the branch being rebased (or empty when rebasing the current branch). # # This sample shows how to prevent topic branches that are already # merged to 'next' branch from getting rebased, because allowing it # would result in rebasing already published history. publish=next basebranch="$1" if test "$#" = 2 then topic="refs/heads/$2" else topic=`git symbolic-ref HEAD` || exit 0 ;# we do not interrupt rebasing detached HEAD fi case "$topic" in refs/heads/??/*) ;; *) exit 0 ;# we do not interrupt others. ;; esac # Now we are dealing with a topic branch being rebased # on top of master. Is it OK to rebase it? # Does the topic really exist? git show-ref -q "$topic" || { echo >&2 "No such branch $topic" exit 1 } # Is topic fully merged to master? not_in_master=`git rev-list --pretty=oneline ^master "$topic"` if test -z "$not_in_master" then echo >&2 "$topic is fully merged to master; better remove it." exit 1 ;# we could allow it, but there is no point. fi # Is topic ever merged to next? If so you should not be rebasing it. only_next_1=`git rev-list ^master "^$topic" ${publish} | sort` only_next_2=`git rev-list ^master ${publish} | sort` if test "$only_next_1" = "$only_next_2" then not_in_topic=`git rev-list "^$topic" master` if test -z "$not_in_topic" then echo >&2 "$topic is already up-to-date with master" exit 1 ;# we could allow it, but there is no point. else exit 0 fi else not_in_next=`git rev-list --pretty=oneline ^${publish} "$topic"` /usr/bin/perl -e ' my $topic = $ARGV[0]; my $msg = "* $topic has commits already merged to public branch:\n"; my (%not_in_next) = map { /^([0-9a-f]+) /; ($1 => 1); } split(/\n/, $ARGV[1]); for my $elem (map { /^([0-9a-f]+) (.*)$/; [$1 => $2]; } split(/\n/, $ARGV[2])) { if (!exists $not_in_next{$elem->[0]}) { if ($msg) { print STDERR $msg; undef $msg; } print STDERR " $elem->[1]\n"; } } ' "$topic" "$not_in_next" "$not_in_master" exit 1 fi exit 0 ################################################################ This sample hook safeguards topic branches that have been published from being rewound. The workflow assumed here is: * Once a topic branch forks from "master", "master" is never merged into it again (either directly or indirectly). * Once a topic branch is fully cooked and merged into "master", it is deleted. If you need to build on top of it to correct earlier mistakes, a new topic branch is created by forking at the tip of the "master". This is not strictly necessary, but it makes it easier to keep your history simple. * Whenever you need to test or publish your changes to topic branches, merge them into "next" branch. The script, being an example, hardcodes the publish branch name to be "next", but it is trivial to make it configurable via $GIT_DIR/config mechanism. With this workflow, you would want to know: (1) ... if a topic branch has ever been merged to "next". Young topic branches can have stupid mistakes you would rather clean up before publishing, and things that have not been merged into other branches can be easily rebased without affecting other people. But once it is published, you would not want to rewind it. (2) ... if a topic branch has been fully merged to "master". Then you can delete it. More importantly, you should not build on top of it -- other people may already want to change things related to the topic as patches against your "master", so if you need further changes, it is better to fork the topic (perhaps with the same name) afresh from the tip of "master". Let's look at this example: o---o---o---o---o---o---o---o---o---o "next" / / / / / a---a---b A / / / / / / / / c---c---c---c B / / / / \ / / / / b---b C \ / / / / / \ / ---o---o---o---o---o---o---o---o---o---o---o "master" A, B and C are topic branches. * A has one fix since it was merged up to "next". * B has finished. It has been fully merged up to "master" and "next", and is ready to be deleted. * C has not merged to "next" at all. We would want to allow C to be rebased, refuse A, and encourage B to be deleted. To compute (1): git rev-list ^master ^topic next git rev-list ^master next if these match, topic has not merged in next at all. To compute (2): git rev-list master..topic if this is empty, it is fully merged to "master". ================================================ FILE: fixtures/test.git/hooks/prepare-commit-msg.sample ================================================ #!/bin/sh # # An example hook script to prepare the commit log message. # Called by "git commit" with the name of the file that has the # commit message, followed by the description of the commit # message's source. The hook's purpose is to edit the commit # message file. If the hook fails with a non-zero status, # the commit is aborted. # # To enable this hook, rename this file to "prepare-commit-msg". # This hook includes three examples. The first comments out the # "Conflicts:" part of a merge commit. # # The second includes the output of "git diff --name-status -r" # into the message, just before the "git status" output. It is # commented because it doesn't cope with --amend or with squashed # commits. # # The third example adds a Signed-off-by line to the message, that can # still be edited. This is rarely a good idea. case "$2,$3" in merge,) /usr/bin/perl -i.bak -ne 's/^/# /, s/^# #/#/ if /^Conflicts/ .. /#/; print' "$1" ;; # ,|template,) # /usr/bin/perl -i.bak -pe ' # print "\n" . `git diff --cached --name-status -r` # if /^#/ && $first++ == 0' "$1" ;; *) ;; esac # SOB=$(git var GIT_AUTHOR_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p') # grep -qs "^$SOB" "$1" || echo "$SOB" >> "$1" ================================================ FILE: fixtures/test.git/hooks/update.sample ================================================ #!/bin/sh # # An example hook script to blocks unannotated tags from entering. # Called by "git receive-pack" with arguments: refname sha1-old sha1-new # # To enable this hook, rename this file to "update". # # Config # ------ # hooks.allowunannotated # This boolean sets whether unannotated tags will be allowed into the # repository. By default they won't be. # hooks.allowdeletetag # This boolean sets whether deleting tags will be allowed in the # repository. By default they won't be. # hooks.allowmodifytag # This boolean sets whether a tag may be modified after creation. By default # it won't be. # hooks.allowdeletebranch # This boolean sets whether deleting branches will be allowed in the # repository. By default they won't be. # hooks.denycreatebranch # This boolean sets whether remotely creating branches will be denied # in the repository. By default this is allowed. # # --- Command line refname="$1" oldrev="$2" newrev="$3" # --- Safety check if [ -z "$GIT_DIR" ]; then echo "Don't run this script from the command line." >&2 echo " (if you want, you could supply GIT_DIR then run" >&2 echo " $0 )" >&2 exit 1 fi if [ -z "$refname" -o -z "$oldrev" -o -z "$newrev" ]; then echo "usage: $0 " >&2 exit 1 fi # --- Config allowunannotated=$(git config --bool hooks.allowunannotated) allowdeletebranch=$(git config --bool hooks.allowdeletebranch) denycreatebranch=$(git config --bool hooks.denycreatebranch) allowdeletetag=$(git config --bool hooks.allowdeletetag) allowmodifytag=$(git config --bool hooks.allowmodifytag) # check for no description projectdesc=$(sed -e '1q' "$GIT_DIR/description") case "$projectdesc" in "Unnamed repository"* | "") echo "*** Project description file hasn't been set" >&2 exit 1 ;; esac # --- Check types # if $newrev is 0000...0000, it's a commit to delete a ref. zero="0000000000000000000000000000000000000000" if [ "$newrev" = "$zero" ]; then newrev_type=delete else newrev_type=$(git cat-file -t $newrev) fi case "$refname","$newrev_type" in refs/tags/*,commit) # un-annotated tag short_refname=${refname##refs/tags/} if [ "$allowunannotated" != "true" ]; then echo "*** The un-annotated tag, $short_refname, is not allowed in this repository" >&2 echo "*** Use 'git tag [ -a | -s ]' for tags you want to propagate." >&2 exit 1 fi ;; refs/tags/*,delete) # delete tag if [ "$allowdeletetag" != "true" ]; then echo "*** Deleting a tag is not allowed in this repository" >&2 exit 1 fi ;; refs/tags/*,tag) # annotated tag if [ "$allowmodifytag" != "true" ] && git rev-parse $refname > /dev/null 2>&1 then echo "*** Tag '$refname' already exists." >&2 echo "*** Modifying a tag is not allowed in this repository." >&2 exit 1 fi ;; refs/heads/*,commit) # branch if [ "$oldrev" = "$zero" -a "$denycreatebranch" = "true" ]; then echo "*** Creating a branch is not allowed in this repository" >&2 exit 1 fi ;; refs/heads/*,delete) # delete branch if [ "$allowdeletebranch" != "true" ]; then echo "*** Deleting a branch is not allowed in this repository" >&2 exit 1 fi ;; refs/remotes/*,commit) # tracking branch ;; refs/remotes/*,delete) # delete tracking branch if [ "$allowdeletebranch" != "true" ]; then echo "*** Deleting a tracking branch is not allowed in this repository" >&2 exit 1 fi ;; *) # Anything else (is there anything else?) echo "*** Update hook: unknown type of update to ref $refname of type $newrev_type" >&2 exit 1 ;; esac # --- Finished exit 0 ================================================ FILE: fixtures/test.git/info/exclude ================================================ # git ls-files --others --exclude-from=.git/info/exclude # Lines that start with '#' are comments. # For a project mostly in C, the following would be a good set of # exclude patterns (uncomment them if you want to use them): # *.[oa] # *~ ================================================ FILE: fixtures/test.git/objects/08/f4b7b6513dffc6245857e497cfd6101dc47818 ================================================ xM 0F]seb~nxcMi)^ߠ7p>xb9U7ub{bgٴă#h8>4o 'yȜ],ڄFk-jUW*zI); ================================================ FILE: fixtures/test.git/objects/9b/5a719a3d76ac9dc2fa635d9b1f34fd73994c06 ================================================ xK 1D]}3$qBϐdq&#^s7UPūXqh/ZM w*cbcG3YIk g 6-Us4i61F#Y,K0iG|=~b7 $b KdD13eKnZC՟{Nm ================================================ FILE: fixtures/test.git/refs/heads/master ================================================ 9b5a719a3d76ac9dc2fa635d9b1f34fd73994c06 ================================================ FILE: fixtures/test_configs.go ================================================ package fixtures import ( "io/ioutil" "os" ) type TestConfigs struct { Path string } func (c *TestConfigs) TearDown() { os.Setenv("HUB_CONFIG", "") os.RemoveAll(c.Path) } func SetupTomlTestConfig() *TestConfigs { file, _ := ioutil.TempFile("", "test-gh-config-") content := `[[hosts]] host = "github.com" user = "jingweno" access_token = "123" protocol = "http"` ioutil.WriteFile(file.Name(), []byte(content), os.ModePerm) os.Setenv("HUB_CONFIG", file.Name()) return &TestConfigs{file.Name()} } func SetupTomlTestConfigWithUnixSocket() *TestConfigs { file, _ := ioutil.TempFile("", "test-gh-config-") content := `[[hosts]] host = "github.com" user = "jingweno" access_token = "123" protocol = "http" unix_socket = "/tmp/go.sock"` ioutil.WriteFile(file.Name(), []byte(content), os.ModePerm) os.Setenv("HUB_CONFIG", file.Name()) return &TestConfigs{file.Name()} } func SetupTestConfigs() *TestConfigs { file, _ := ioutil.TempFile("", "test-gh-config-") content := `--- github.com: - user: jingweno oauth_token: "123" protocol: http` ioutil.WriteFile(file.Name(), []byte(content), os.ModePerm) os.Setenv("HUB_CONFIG", file.Name()) return &TestConfigs{file.Name()} } func SetupTestConfigsWithUnixSocket() *TestConfigs { file, _ := ioutil.TempFile("", "test-gh-config-") content := `--- github.com: - user: jingweno oauth_token: "123" protocol: http unix_socket: /tmp/go.sock` ioutil.WriteFile(file.Name(), []byte(content), os.ModePerm) os.Setenv("HUB_CONFIG", file.Name()) return &TestConfigs{file.Name()} } func SetupTestConfigsInvalidHostName() *TestConfigs { file, _ := ioutil.TempFile("", "test-gh-config-") content := `--- 123: - user: jingweno oauth_token: "123" protocol: http unix_socket: /tmp/go.sock` ioutil.WriteFile(file.Name(), []byte(content), os.ModePerm) os.Setenv("HUB_CONFIG", file.Name()) return &TestConfigs{file.Name()} } func SetupTestConfigsInvalidHostEntry() *TestConfigs { file, _ := ioutil.TempFile("", "test-gh-config-") content := `--- github.com: hello` ioutil.WriteFile(file.Name(), []byte(content), os.ModePerm) os.Setenv("HUB_CONFIG", file.Name()) return &TestConfigs{file.Name()} } func SetupTestConfigsInvalidPropertyValue() *TestConfigs { file, _ := ioutil.TempFile("", "test-gh-config-") content := `--- github.com: - user: oauth_token: "123" protocol: http unix_socket: /tmp/go.sock` ioutil.WriteFile(file.Name(), []byte(content), os.ModePerm) os.Setenv("HUB_CONFIG", file.Name()) return &TestConfigs{file.Name()} } ================================================ FILE: fixtures/test_repo.go ================================================ package fixtures import ( "fmt" "io/ioutil" "os" "path/filepath" "github.com/github/hub/v2/cmd" ) type TestRepo struct { Remote string TearDown func() } func (r *TestRepo) AddRemote(name, url, pushURL string) { add := cmd.New("git").WithArgs("remote", "add", name, url) if _, err := add.CombinedOutput(); err != nil { panic(err) } if pushURL != "" { set := cmd.New("git").WithArgs("remote", "set-url", "--push", name, pushURL) if _, err := set.CombinedOutput(); err != nil { panic(err) } } } func (r *TestRepo) AddFile(filePath string, content string) { path := filepath.Join(os.Getenv("HOME"), filePath) err := os.MkdirAll(filepath.Dir(path), 0771) if err != nil { panic(err) } ioutil.WriteFile(path, []byte(content), os.ModePerm) } func SetupTestRepo() *TestRepo { pwd, _ := os.Getwd() oldEnv := make(map[string]string) overrideEnv := func(name, value string) { oldEnv[name] = os.Getenv(name) os.Setenv(name, value) } remotePath := filepath.Join(pwd, "..", "fixtures", "test.git") home, err := ioutil.TempDir("", "test-repo") if err != nil { panic(err) } overrideEnv("HOME", home) overrideEnv("XDG_CONFIG_HOME", "") overrideEnv("XDG_CONFIG_DIRS", "") targetPath := filepath.Join(home, "test.git") cmd := cmd.New("git").WithArgs("clone", remotePath, targetPath) if output, err := cmd.CombinedOutput(); err != nil { panic(fmt.Errorf("error running %s\n%s\n%s", cmd, err, output)) } if err = os.Chdir(targetPath); err != nil { panic(err) } tearDown := func() { if err := os.Chdir(pwd); err != nil { panic(err) } for name, value := range oldEnv { os.Setenv(name, value) } if err = os.RemoveAll(home); err != nil { panic(err) } } return &TestRepo{Remote: remotePath, TearDown: tearDown} } ================================================ FILE: git/git.go ================================================ package git import ( "fmt" "os" "path/filepath" "strings" "github.com/github/hub/v2/cmd" ) var GlobalFlags []string func Version() (string, error) { versionCmd := gitCmd("version") output, err := versionCmd.Output() if err != nil { return "", fmt.Errorf("error running git version: %s", err) } return firstLine(output), nil } var cachedDir string func Dir() (string, error) { if cachedDir != "" { return cachedDir, nil } dirCmd := gitCmd("rev-parse", "-q", "--git-dir") dirCmd.Stderr = nil output, err := dirCmd.Output() if err != nil { return "", fmt.Errorf("Not a git repository (or any of the parent directories): .git") } var chdir string for i, flag := range GlobalFlags { if flag == "-C" { dir := GlobalFlags[i+1] if filepath.IsAbs(dir) { chdir = dir } else { chdir = filepath.Join(chdir, dir) } } } gitDir := firstLine(output) if !filepath.IsAbs(gitDir) { if chdir != "" { gitDir = filepath.Join(chdir, gitDir) } gitDir, err = filepath.Abs(gitDir) if err != nil { return "", err } gitDir = filepath.Clean(gitDir) } cachedDir = gitDir return gitDir, nil } func WorkdirName() (string, error) { toplevelCmd := gitCmd("rev-parse", "--show-toplevel") toplevelCmd.Stderr = nil output, err := toplevelCmd.Output() dir := firstLine(output) if dir == "" { return "", fmt.Errorf("unable to determine git working directory") } return dir, err } func HasFile(segments ...string) bool { // The blessed way to resolve paths within git dir since Git 2.5.0 pathCmd := gitCmd("rev-parse", "-q", "--git-path", filepath.Join(segments...)) pathCmd.Stderr = nil if output, err := pathCmd.Output(); err == nil { if lines := outputLines(output); len(lines) == 1 { if _, err := os.Stat(lines[0]); err == nil { return true } } } // Fallback for older git versions dir, err := Dir() if err != nil { return false } s := []string{dir} s = append(s, segments...) path := filepath.Join(s...) if _, err := os.Stat(path); err == nil { return true } return false } func Editor() (string, error) { varCmd := gitCmd("var", "GIT_EDITOR") varCmd.Stderr = nil output, err := varCmd.Output() if err != nil { return "", fmt.Errorf("Can't load git var: GIT_EDITOR") } return os.ExpandEnv(firstLine(output)), nil } func Head() (string, error) { return SymbolicRef("HEAD") } // SymbolicRef reads a branch name from a ref such as "HEAD" func SymbolicRef(ref string) (string, error) { refCmd := gitCmd("symbolic-ref", ref) refCmd.Stderr = nil output, err := refCmd.Output() return firstLine(output), err } // SymbolicFullName reads a branch name from a ref such as "@{upstream}" func SymbolicFullName(name string) (string, error) { parseCmd := gitCmd("rev-parse", "--symbolic-full-name", name) parseCmd.Stderr = nil output, err := parseCmd.Output() if err != nil { return "", fmt.Errorf("Unknown revision or path not in the working tree: %s", name) } return firstLine(output), nil } func Ref(ref string) (string, error) { parseCmd := gitCmd("rev-parse", "-q", ref) parseCmd.Stderr = nil output, err := parseCmd.Output() if err != nil { return "", fmt.Errorf("Unknown revision or path not in the working tree: %s", ref) } return firstLine(output), nil } func RefList(a, b string) ([]string, error) { ref := fmt.Sprintf("%s...%s", a, b) listCmd := gitCmd("rev-list", "--cherry-pick", "--right-only", "--no-merges", ref) listCmd.Stderr = nil output, err := listCmd.Output() if err != nil { return nil, fmt.Errorf("Can't load rev-list for %s", ref) } return outputLines(output), nil } func NewRange(a, b string) (*Range, error) { parseCmd := gitCmd("rev-parse", "-q", a, b) parseCmd.Stderr = nil output, err := parseCmd.Output() if err != nil { return nil, err } lines := outputLines(output) if len(lines) != 2 { return nil, fmt.Errorf("Can't parse range %s..%s", a, b) } return &Range{lines[0], lines[1]}, nil } type Range struct { A string B string } func (r *Range) IsIdentical() bool { return strings.EqualFold(r.A, r.B) } func (r *Range) IsAncestor() bool { cmd := gitCmd("merge-base", "--is-ancestor", r.A, r.B) return cmd.Success() } func CommentChar(text string) (string, error) { char, err := Config("core.commentchar") if err != nil { return "#", nil } else if char == "auto" { lines := strings.Split(text, "\n") commentCharCandidates := strings.Split("#;@!$%^&|:", "") candidateLoop: for _, candidate := range commentCharCandidates { for _, line := range lines { if strings.HasPrefix(line, candidate) { continue candidateLoop } } return candidate, nil } return "", fmt.Errorf("unable to select a comment character that is not used in the current message") } else { return char, nil } } func Show(sha string) (string, error) { cmd := cmd.New("git") cmd.Stderr = nil cmd.WithArg("-c").WithArg("log.showSignature=false") cmd.WithArg("show").WithArg("-s").WithArg("--format=%s%n%+b").WithArg(sha) output, err := cmd.Output() return strings.TrimSpace(output), err } func Log(sha1, sha2 string) (string, error) { execCmd := cmd.New("git") execCmd.WithArg("-c").WithArg("log.showSignature=false").WithArg("log").WithArg("--no-color") execCmd.WithArg("--format=%h (%aN, %ar)%n%w(78,3,3)%s%n%+b") execCmd.WithArg("--cherry") shaRange := fmt.Sprintf("%s...%s", sha1, sha2) execCmd.WithArg(shaRange) outputs, err := execCmd.Output() if err != nil { return "", fmt.Errorf("Can't load git log %s..%s", sha1, sha2) } return outputs, nil } func Remotes() ([]string, error) { remoteCmd := gitCmd("remote", "-v") remoteCmd.Stderr = nil output, err := remoteCmd.Output() return outputLines(output), err } func Config(name string) (string, error) { return gitGetConfig(name) } func ConfigAll(name string) ([]string, error) { mode := "--get-all" if strings.Contains(name, "*") { mode = "--get-regexp" } configCmd := gitCmd(gitConfigCommand([]string{mode, name})...) output, err := configCmd.Output() if err != nil { return nil, fmt.Errorf("Unknown config %s", name) } return outputLines(output), nil } func GlobalConfig(name string) (string, error) { return gitGetConfig("--global", name) } func SetGlobalConfig(name, value string) error { _, err := gitConfig("--global", name, value) return err } func gitGetConfig(args ...string) (string, error) { configCmd := gitCmd(gitConfigCommand(args)...) output, err := configCmd.Output() if err != nil { return "", fmt.Errorf("Unknown config %s", args[len(args)-1]) } return firstLine(output), nil } func gitConfig(args ...string) ([]string, error) { configCmd := gitCmd(gitConfigCommand(args)...) output, err := configCmd.Output() return outputLines(output), err } func gitConfigCommand(args []string) []string { cmd := []string{"config"} return append(cmd, args...) } func Alias(name string) (string, error) { return Config(fmt.Sprintf("alias.%s", name)) } func Run(args ...string) error { cmd := gitCmd(args...) return cmd.Run() } func Spawn(args ...string) error { cmd := gitCmd(args...) return cmd.Spawn() } func Quiet(args ...string) bool { cmd := gitCmd(args...) return cmd.Success() } func IsGitDir(dir string) bool { cmd := cmd.New("git") cmd.WithArgs("--git-dir="+dir, "rev-parse", "--git-dir") return cmd.Success() } func LocalBranches() ([]string, error) { branchesCmd := gitCmd("branch", "--list") output, err := branchesCmd.Output() if err != nil { return nil, err } branches := []string{} for _, branch := range outputLines(output) { branches = append(branches, branch[2:]) } return branches, nil } func outputLines(output string) []string { output = strings.TrimSuffix(output, "\n") if output == "" { return []string{} } return strings.Split(output, "\n") } func firstLine(output string) string { if i := strings.Index(output, "\n"); i >= 0 { return output[0:i] } return output } func gitCmd(args ...string) *cmd.Cmd { cmd := cmd.New("git") for _, v := range GlobalFlags { cmd.WithArg(v) } for _, a := range args { cmd.WithArg(a) } return cmd } func IsBuiltInGitCommand(command string) bool { helpCommand := gitCmd("help", "--no-verbose", "-a") helpCommand.Stderr = nil helpCommandOutput, err := helpCommand.Output() if err != nil { // support git versions that don't recognize --no-verbose helpCommand := gitCmd("help", "-a") helpCommandOutput, err = helpCommand.Output() } if err != nil { return false } for _, helpCommandOutputLine := range outputLines(helpCommandOutput) { if strings.HasPrefix(helpCommandOutputLine, " ") { for _, gitCommand := range strings.Split(helpCommandOutputLine, " ") { if gitCommand == command { return true } } } } return false } ================================================ FILE: git/git_test.go ================================================ package git import ( "fmt" "os" "strings" "testing" "github.com/github/hub/v2/fixtures" "github.com/github/hub/v2/internal/assert" ) func TestGitDir(t *testing.T) { repo := fixtures.SetupTestRepo() defer repo.TearDown() gitDir, _ := Dir() assert.T(t, strings.Contains(gitDir, ".git")) } func TestGitEditor(t *testing.T) { repo := fixtures.SetupTestRepo() editor := os.Getenv("GIT_EDITOR") if err := os.Unsetenv("GIT_EDITOR"); err != nil { t.Fatal(err) } defer func() { repo.TearDown() if err := os.Setenv("GIT_EDITOR", editor); err != nil { t.Fatal(err) } os.Unsetenv("FOO") os.Unsetenv("BAR") }() os.Setenv("FOO", "hello") os.Setenv("BAR", "happy world") SetGlobalConfig("core.editor", `$FOO "${BAR}"`) gitEditor, err := Editor() assert.Equal(t, nil, err) assert.Equal(t, `hello "happy world"`, gitEditor) } func TestGitLog(t *testing.T) { repo := fixtures.SetupTestRepo() defer repo.TearDown() log, err := Log("08f4b7b6513dffc6245857e497cfd6101dc47818", "9b5a719a3d76ac9dc2fa635d9b1f34fd73994c06") assert.Equal(t, nil, err) assert.NotEqual(t, "", log) } func TestGitRef(t *testing.T) { repo := fixtures.SetupTestRepo() defer repo.TearDown() ref := "08f4b7b6513dffc6245857e497cfd6101dc47818" gitRef, err := Ref(ref) assert.Equal(t, nil, err) assert.Equal(t, ref, gitRef) } func TestGitRefList(t *testing.T) { repo := fixtures.SetupTestRepo() defer repo.TearDown() refList, err := RefList("08f4b7b6513dffc6245857e497cfd6101dc47818", "9b5a719a3d76ac9dc2fa635d9b1f34fd73994c06") assert.Equal(t, nil, err) assert.Equal(t, 1, len(refList)) assert.Equal(t, "9b5a719a3d76ac9dc2fa635d9b1f34fd73994c06", refList[0]) } func TestGitShow(t *testing.T) { repo := fixtures.SetupTestRepo() defer repo.TearDown() output, err := Show("9b5a719a3d76ac9dc2fa635d9b1f34fd73994c06") assert.Equal(t, nil, err) assert.Equal(t, "First comment\n\nMore comment", output) } func TestGitConfig(t *testing.T) { repo := fixtures.SetupTestRepo() defer repo.TearDown() _, err := GlobalConfig("hub.test") assert.NotEqual(t, nil, err) SetGlobalConfig("hub.test", "1") v, err := GlobalConfig("hub.test") assert.Equal(t, nil, err) assert.Equal(t, "1", v) SetGlobalConfig("hub.test", "") v, err = GlobalConfig("hub.test") assert.Equal(t, nil, err) assert.Equal(t, "", v) } func TestRemotes(t *testing.T) { repo := fixtures.SetupTestRepo() defer repo.TearDown() type remote struct { name string url string pushURL string } testCases := map[string]remote{ "testremote1": { "testremote1", "https://example.com/test1/project1.git", "no_push", }, "testremote2": { "testremote2", "user@example.com:test2/project2.git", "http://example.com/project.git", }, "testremote3": { "testremote3", "https://example.com/test1/project2.git", "", }, } for _, tc := range testCases { repo.AddRemote(tc.name, tc.url, tc.pushURL) } remotes, err := Remotes() assert.Equal(t, nil, err) // In addition to the remotes we added to the repo, repo will // also have an additional remote "origin". So add it to the // expected cases to test. wantCases := map[string]struct{}{ fmt.Sprintf("origin %s (fetch)", repo.Remote): {}, fmt.Sprintf("origin %s (push)", repo.Remote): {}, "testremote1 https://example.com/test1/project1.git (fetch)": {}, "testremote1 no_push (push)": {}, "testremote2 user@example.com:test2/project2.git (fetch)": {}, "testremote2 http://example.com/project.git (push)": {}, "testremote3 https://example.com/test1/project2.git (fetch)": {}, "testremote3 https://example.com/test1/project2.git (push)": {}, } assert.Equal(t, len(remotes), len(wantCases)) for _, got := range remotes { if _, ok := wantCases[got]; !ok { t.Errorf("Unexpected remote: %s", got) } } } func TestCommentChar(t *testing.T) { repo := fixtures.SetupTestRepo() defer repo.TearDown() char, err := CommentChar("") assert.Equal(t, nil, err) assert.Equal(t, "#", char) SetGlobalConfig("core.commentchar", ";") char, err = CommentChar("") assert.Equal(t, nil, err) assert.Equal(t, ";", char) SetGlobalConfig("core.commentchar", "auto") char, err = CommentChar("") assert.Equal(t, nil, err) assert.Equal(t, "#", char) char, err = CommentChar("hello\n#nice\nworld") assert.Equal(t, nil, err) assert.Equal(t, ";", char) char, err = CommentChar("hello\n#nice\n;world") assert.Equal(t, nil, err) assert.Equal(t, "@", char) _, err = CommentChar("#\n;\n@\n!\n$\n%\n^\n&\n|\n:") assert.Equal(t, "unable to select a comment character that is not used in the current message", err.Error()) } ================================================ FILE: git/ssh_config.go ================================================ package git import ( "bufio" "os" "path/filepath" "regexp" "strings" "github.com/mitchellh/go-homedir" ) const ( hostReStr = "(?i)^[ \t]*(host|hostname)[ \t]+(.+)$" ) type SSHConfig map[string]string func newSSHConfigReader() *SSHConfigReader { configFiles := []string{ "/etc/ssh_config", "/etc/ssh/ssh_config", } if homedir, err := homedir.Dir(); err == nil { userConfig := filepath.Join(homedir, ".ssh", "config") configFiles = append([]string{userConfig}, configFiles...) } return &SSHConfigReader{ Files: configFiles, } } type SSHConfigReader struct { Files []string } func (r *SSHConfigReader) Read() SSHConfig { config := make(SSHConfig) hostRe := regexp.MustCompile(hostReStr) for _, filename := range r.Files { r.readFile(config, hostRe, filename) } return config } func (r *SSHConfigReader) readFile(c SSHConfig, re *regexp.Regexp, f string) error { file, err := os.Open(f) if err != nil { return err } defer file.Close() hosts := []string{"*"} scanner := bufio.NewScanner(file) for scanner.Scan() { line := scanner.Text() match := re.FindStringSubmatch(line) if match == nil { continue } names := strings.Fields(match[2]) if strings.EqualFold(match[1], "host") { hosts = names } else { for _, host := range hosts { for _, name := range names { c[host] = expandTokens(name, host) } } } } return scanner.Err() } func expandTokens(text, host string) string { re := regexp.MustCompile(`%[%h]`) return re.ReplaceAllStringFunc(text, func(match string) string { switch match { case "%h": return host case "%%": return "%" } return "" }) } ================================================ FILE: git/ssh_config_test.go ================================================ package git import ( "io/ioutil" "os" "testing" "github.com/github/hub/v2/internal/assert" ) func TestSSHConfigReader_Read(t *testing.T) { f, _ := ioutil.TempFile("", "ssh-config") c := `Host github.com Hostname ssh.github.com Port 443 host other Hostname 10.0.0.1 ` ioutil.WriteFile(f.Name(), []byte(c), os.ModePerm) r := &SSHConfigReader{[]string{f.Name()}} sc := r.Read() assert.Equal(t, "ssh.github.com", sc["github.com"]) } func TestSSHConfigReader_ExpandTokens(t *testing.T) { f, _ := ioutil.TempFile("", "ssh-config") c := `Host github.com example.org Hostname 1-%h-2-%%h-3-%h-%% ` ioutil.WriteFile(f.Name(), []byte(c), os.ModePerm) r := &SSHConfigReader{[]string{f.Name()}} sc := r.Read() assert.Equal(t, "1-github.com-2-%h-3-github.com-%", sc["github.com"]) assert.Equal(t, "1-example.org-2-%h-3-example.org-%", sc["example.org"]) } ================================================ FILE: git/url.go ================================================ package git import ( "net/url" "regexp" "strings" ) var ( cachedSSHConfig SSHConfig protocolRe = regexp.MustCompile("^[a-zA-Z_+-]+://") ) type URLParser struct { SSHConfig SSHConfig } func (p *URLParser) Parse(rawURL string) (u *url.URL, err error) { if !protocolRe.MatchString(rawURL) && strings.Contains(rawURL, ":") && // not a Windows path !strings.Contains(rawURL, "\\") { rawURL = "ssh://" + strings.Replace(rawURL, ":", "/", 1) } u, err = url.Parse(rawURL) if err != nil { return } if u.Scheme == "git+ssh" { u.Scheme = "ssh" } if u.Scheme != "ssh" { return } if strings.HasPrefix(u.Path, "//") { u.Path = strings.TrimPrefix(u.Path, "/") } if idx := strings.Index(u.Host, ":"); idx >= 0 { u.Host = u.Host[0:idx] } sshHost := p.SSHConfig[u.Host] // ignore replacing host that fixes for limited network // https://help.github.com/articles/using-ssh-over-the-https-port ignoredHost := u.Host == "github.com" && sshHost == "ssh.github.com" if !ignoredHost && sshHost != "" { u.Host = sshHost } return } func ParseURL(rawURL string) (u *url.URL, err error) { if cachedSSHConfig == nil { cachedSSHConfig = newSSHConfigReader().Read() } p := &URLParser{cachedSSHConfig} return p.Parse(rawURL) } ================================================ FILE: git/url_test.go ================================================ package git import ( "testing" "github.com/github/hub/v2/internal/assert" ) func createURLParser() *URLParser { c := make(SSHConfig) c["github.com"] = "ssh.github.com" c["gh"] = "github.com" c["git.company.com"] = "ssh.git.company.com" return &URLParser{c} } func TestURLParser_ParseURL_HTTPURL(t *testing.T) { p := createURLParser() u, err := p.Parse("https://github.com/octokit/go-octokit.git") assert.Equal(t, nil, err) assert.Equal(t, "github.com", u.Host) assert.Equal(t, "https", u.Scheme) assert.Equal(t, "/octokit/go-octokit.git", u.Path) } func TestURLParser_ParseURL_GitURL(t *testing.T) { p := createURLParser() u, err := p.Parse("git://github.com/octokit/go-octokit.git") assert.Equal(t, nil, err) assert.Equal(t, "github.com", u.Host) assert.Equal(t, "git", u.Scheme) assert.Equal(t, "/octokit/go-octokit.git", u.Path) u, err = p.Parse("https://git.company.com/octokit/go-octokit.git") assert.Equal(t, nil, err) assert.Equal(t, "git.company.com", u.Host) assert.Equal(t, "https", u.Scheme) assert.Equal(t, "/octokit/go-octokit.git", u.Path) u, err = p.Parse("git://git.company.com/octokit/go-octokit.git") assert.Equal(t, nil, err) assert.Equal(t, "git.company.com", u.Host) assert.Equal(t, "git", u.Scheme) assert.Equal(t, "/octokit/go-octokit.git", u.Path) } func TestURLParser_ParseURL_SSHURL(t *testing.T) { p := createURLParser() u, err := p.Parse("git@github.com:lostisland/go-sawyer.git") assert.Equal(t, nil, err) assert.Equal(t, "github.com", u.Host) assert.Equal(t, "ssh", u.Scheme) assert.Equal(t, "git", u.User.Username()) assert.Equal(t, "/lostisland/go-sawyer.git", u.Path) u, err = p.Parse("gh:octokit/go-octokit") assert.Equal(t, nil, err) assert.Equal(t, "github.com", u.Host) assert.Equal(t, "ssh", u.Scheme) assert.Equal(t, "/octokit/go-octokit", u.Path) u, err = p.Parse("git@git.company.com:octokit/go-octokit") assert.Equal(t, nil, err) assert.Equal(t, "ssh.git.company.com", u.Host) assert.Equal(t, "ssh", u.Scheme) assert.Equal(t, "/octokit/go-octokit", u.Path) } func TestURLParser_ParseURL_LocalPath(t *testing.T) { p := createURLParser() u, err := p.Parse("/path/to/repo.git") assert.Equal(t, nil, err) assert.Equal(t, "", u.Host) assert.Equal(t, "", u.Scheme) assert.Equal(t, "/path/to/repo.git", u.Path) u, err = p.Parse(`c:\path\to\repo.git`) assert.Equal(t, nil, err) assert.Equal(t, `c:\path\to\repo.git`, u.String()) } ================================================ FILE: github/branch.go ================================================ package github import ( "fmt" "regexp" "strings" "github.com/github/hub/v2/git" ) type Branch struct { Repo *GitHubRepo Name string } func (b *Branch) ShortName() string { reg := regexp.MustCompile("^refs/(remotes/)?.+?/") return reg.ReplaceAllString(b.Name, "") } func (b *Branch) LongName() string { reg := regexp.MustCompile("^refs/(remotes/)?") return reg.ReplaceAllString(b.Name, "") } func (b *Branch) RemoteName() string { reg := regexp.MustCompile("^refs/remotes/([^/]+)") if reg.MatchString(b.Name) { return reg.FindStringSubmatch(b.Name)[1] } return "" } func (b *Branch) Upstream() (u *Branch, err error) { name, err := git.SymbolicFullName(fmt.Sprintf("%s@{upstream}", b.ShortName())) if err != nil { return } u = &Branch{b.Repo, name} return } func (b *Branch) IsMaster() bool { masterName := b.Repo.MasterBranch().ShortName() return b.ShortName() == masterName } func (b *Branch) IsRemote() bool { return strings.HasPrefix(b.Name, "refs/remotes") } ================================================ FILE: github/branch_test.go ================================================ package github import ( "testing" "github.com/github/hub/v2/internal/assert" ) func TestBranch_ShortName(t *testing.T) { lp, _ := LocalRepo() b := Branch{lp, "refs/heads/master"} assert.Equal(t, "master", b.ShortName()) } func TestBranch_LongName(t *testing.T) { lp, _ := LocalRepo() b := Branch{lp, "refs/heads/master"} assert.Equal(t, "heads/master", b.LongName()) b = Branch{lp, "refs/remotes/origin/master"} assert.Equal(t, "origin/master", b.LongName()) } func TestBranch_RemoteName(t *testing.T) { lp, _ := LocalRepo() b := Branch{lp, "refs/remotes/origin/master"} assert.Equal(t, "origin", b.RemoteName()) b = Branch{lp, "refs/head/master"} assert.Equal(t, "", b.RemoteName()) } func TestBranch_IsRemote(t *testing.T) { lp, _ := LocalRepo() b := Branch{lp, "refs/remotes/origin/master"} assert.T(t, b.IsRemote()) } ================================================ FILE: github/client.go ================================================ package github import ( "bytes" "encoding/json" "errors" "fmt" "io" "io/ioutil" "net/http" "net/url" "os" "os/exec" "path" "path/filepath" "sort" "strings" "time" "github.com/github/hub/v2/version" ) const ( GitHubHost string = "github.com" OAuthAppURL string = "https://hub.github.com/" ) var UserAgent = "Hub " + version.Version func NewClient(h string) *Client { return NewClientWithHost(&Host{Host: h}) } func NewClientWithHost(host *Host) *Client { return &Client{Host: host} } type Client struct { Host *Host cachedClient *simpleClient } type Gist struct { Files map[string]GistFile `json:"files"` Description string `json:"description,omitempty"` ID string `json:"id,omitempty"` Public bool `json:"public"` HTMLURL string `json:"html_url"` } type GistFile struct { Type string `json:"type,omitempty"` Language string `json:"language,omitempty"` Content string `json:"content"` RawURL string `json:"raw_url"` } func (client *Client) FetchPullRequests(project *Project, filterParams map[string]interface{}, limit int, filter func(*PullRequest) bool) (pulls []PullRequest, err error) { api, err := client.simpleAPI() if err != nil { return } path := fmt.Sprintf("repos/%s/%s/pulls?per_page=%d", project.Owner, project.Name, perPage(limit, 100)) if filterParams != nil { path = addQuery(path, filterParams) } pulls = []PullRequest{} var res *simpleResponse for path != "" { res, err = api.GetFile(path, draftsType) if err = checkStatus(200, "fetching pull requests", res, err); err != nil { return } path = res.Link("next") pullsPage := []PullRequest{} if err = res.Unmarshal(&pullsPage); err != nil { return } for _, pr := range pullsPage { if filter == nil || filter(&pr) { pulls = append(pulls, pr) if limit > 0 && len(pulls) == limit { path = "" break } } } } return } func (client *Client) PullRequest(project *Project, id string) (pr *PullRequest, err error) { api, err := client.simpleAPI() if err != nil { return } res, err := api.Get(fmt.Sprintf("repos/%s/%s/pulls/%s", project.Owner, project.Name, id)) if err = checkStatus(200, "getting pull request", res, err); err != nil { return } pr = &PullRequest{} err = res.Unmarshal(pr) return } func (client *Client) PullRequestPatch(project *Project, id string) (patch io.ReadCloser, err error) { api, err := client.simpleAPI() if err != nil { return } res, err := api.GetFile(fmt.Sprintf("repos/%s/%s/pulls/%s", project.Owner, project.Name, id), patchMediaType) if err = checkStatus(200, "getting pull request patch", res, err); err != nil { return } return res.Body, nil } func (client *Client) CreatePullRequest(project *Project, params map[string]interface{}) (pr *PullRequest, err error) { api, err := client.simpleAPI() if err != nil { return } res, err := api.PostJSONPreview(fmt.Sprintf("repos/%s/%s/pulls", project.Owner, project.Name), params, draftsType) if err = checkStatus(201, "creating pull request", res, err); err != nil { if res != nil && res.StatusCode == 404 { projectURL := strings.SplitN(project.WebURL("", "", ""), "://", 2)[1] err = fmt.Errorf("%s\nAre you sure that %s exists?", err, projectURL) } return } pr = &PullRequest{} err = res.Unmarshal(pr) return } type PullRequestMergeResponse struct { SHA string Merged bool Message string } func (client *Client) MergePullRequest(project *Project, prNumber int, params map[string]interface{}) (mr PullRequestMergeResponse, err error) { api, err := client.simpleAPI() if err != nil { return } res, err := api.PutJSON(fmt.Sprintf("repos/%s/%s/pulls/%d/merge", project.Owner, project.Name, prNumber), params) if err = checkStatus(200, "merging pull request", res, err); err != nil { return } defer res.Body.Close() err = res.Unmarshal(&mr) return } func (client *Client) DeleteBranch(project *Project, branchName string) (err error) { api, err := client.simpleAPI() if err != nil { return } res, err := api.Delete(fmt.Sprintf("repos/%s/%s/git/refs/heads/%s", project.Owner, project.Name, branchName)) if err == nil { defer res.Body.Close() if res.StatusCode == 422 { return } } if err = checkStatus(204, "deleting branch", res, err); err != nil { return } return } func (client *Client) RequestReview(project *Project, prNumber int, params map[string]interface{}) (err error) { api, err := client.simpleAPI() if err != nil { return } res, err := api.PostJSON(fmt.Sprintf("repos/%s/%s/pulls/%d/requested_reviewers", project.Owner, project.Name, prNumber), params) if err = checkStatus(201, "requesting reviewer", res, err); err != nil { return } res.Body.Close() return } func (client *Client) CommitPatch(project *Project, sha string) (patch io.ReadCloser, err error) { api, err := client.simpleAPI() if err != nil { return } res, err := api.GetFile(fmt.Sprintf("repos/%s/%s/commits/%s", project.Owner, project.Name, sha), patchMediaType) if err = checkStatus(200, "getting commit patch", res, err); err != nil { return } return res.Body, nil } func (client *Client) GistPatch(id string) (patch io.ReadCloser, err error) { api, err := client.simpleAPI() if err != nil { return } res, err := api.Get(fmt.Sprintf("gists/%s", id)) if err = checkStatus(200, "getting gist patch", res, err); err != nil { return } gist := Gist{} if err = res.Unmarshal(&gist); err != nil { return } rawURL := "" for _, file := range gist.Files { rawURL = file.RawURL break } res, err = api.GetFile(rawURL, textMediaType) if err = checkStatus(200, "getting gist patch", res, err); err != nil { return } return res.Body, nil } func (client *Client) Repository(project *Project) (repo *Repository, err error) { api, err := client.simpleAPI() if err != nil { return } res, err := api.Get(fmt.Sprintf("repos/%s/%s", project.Owner, project.Name)) if err = checkStatus(200, "getting repository info", res, err); err != nil { return } repo = &Repository{} err = res.Unmarshal(&repo) return } func (client *Client) CreateRepository(project *Project, description, homepage string, isPrivate bool) (repo *Repository, err error) { repoURL := "user/repos" if project.Owner != client.Host.User { repoURL = fmt.Sprintf("orgs/%s/repos", project.Owner) } params := map[string]interface{}{ "name": project.Name, "description": description, "homepage": homepage, "private": isPrivate, } api, err := client.simpleAPI() if err != nil { return } res, err := api.PostJSON(repoURL, params) if err = checkStatus(201, "creating repository", res, err); err != nil { return } repo = &Repository{} err = res.Unmarshal(repo) return } func (client *Client) DeleteRepository(project *Project) error { api, err := client.simpleAPI() if err != nil { return err } repoURL := fmt.Sprintf("repos/%s/%s", project.Owner, project.Name) res, err := api.Delete(repoURL) return checkStatus(204, "deleting repository", res, err) } type Release struct { Name string `json:"name"` TagName string `json:"tag_name"` TargetCommitish string `json:"target_commitish"` Body string `json:"body"` Draft bool `json:"draft"` Prerelease bool `json:"prerelease"` Assets []ReleaseAsset `json:"assets"` TarballURL string `json:"tarball_url"` ZipballURL string `json:"zipball_url"` HTMLURL string `json:"html_url"` UploadURL string `json:"upload_url"` APIURL string `json:"url"` CreatedAt time.Time `json:"created_at"` PublishedAt time.Time `json:"published_at"` } type ReleaseAsset struct { Name string `json:"name"` Label string `json:"label"` DownloadURL string `json:"browser_download_url"` APIURL string `json:"url"` } func (client *Client) FetchReleases(project *Project, limit int, filter func(*Release) bool) (releases []Release, err error) { api, err := client.simpleAPI() if err != nil { return } path := fmt.Sprintf("repos/%s/%s/releases?per_page=%d", project.Owner, project.Name, perPage(limit, 100)) releases = []Release{} var res *simpleResponse for path != "" { res, err = api.Get(path) if err = checkStatus(200, "fetching releases", res, err); err != nil { return } path = res.Link("next") releasesPage := []Release{} if err = res.Unmarshal(&releasesPage); err != nil { return } for _, release := range releasesPage { if filter == nil || filter(&release) { releases = append(releases, release) if limit > 0 && len(releases) == limit { path = "" break } } } } return } func (client *Client) FetchRelease(project *Project, tagName string) (*Release, error) { releases, err := client.FetchReleases(project, 100, func(release *Release) bool { return release.TagName == tagName }) if err != nil { return nil, err } if len(releases) < 1 { return nil, fmt.Errorf("Unable to find release with tag name `%s'", tagName) } return &releases[0], nil } func (client *Client) CreateRelease(project *Project, releaseParams *Release) (release *Release, err error) { api, err := client.simpleAPI() if err != nil { return } res, err := api.PostJSON(fmt.Sprintf("repos/%s/%s/releases", project.Owner, project.Name), releaseParams) if err = checkStatus(201, "creating release", res, err); err != nil { return } release = &Release{} err = res.Unmarshal(release) return } func (client *Client) EditRelease(release *Release, releaseParams map[string]interface{}) (updatedRelease *Release, err error) { api, err := client.simpleAPI() if err != nil { return } res, err := api.PatchJSON(release.APIURL, releaseParams) if err = checkStatus(200, "editing release", res, err); err != nil { return } updatedRelease = &Release{} err = res.Unmarshal(updatedRelease) return } func (client *Client) DeleteRelease(release *Release) (err error) { api, err := client.simpleAPI() if err != nil { return } res, err := api.Delete(release.APIURL) if err = checkStatus(204, "deleting release", res, err); err != nil { return } return } type LocalAsset struct { Name string Label string Contents io.Reader Size int64 } func (client *Client) UploadReleaseAssets(release *Release, assets []LocalAsset) (doneAssets []*ReleaseAsset, err error) { api, err := client.simpleAPI() if err != nil { return } idx := strings.Index(release.UploadURL, "{") uploadURL := release.UploadURL[0:idx] for _, asset := range assets { for _, existingAsset := range release.Assets { if existingAsset.Name == asset.Name { if err = client.DeleteReleaseAsset(&existingAsset); err != nil { return } break } } params := map[string]interface{}{"name": filepath.Base(asset.Name)} if asset.Label != "" { params["label"] = asset.Label } uploadPath := addQuery(uploadURL, params) var res *simpleResponse attempts := 0 maxAttempts := 3 body := asset.Contents for { res, err = api.PostFile(uploadPath, body, asset.Size) if err == nil && res.StatusCode >= 500 && res.StatusCode < 600 && attempts < maxAttempts { attempts++ time.Sleep(time.Second * time.Duration(attempts)) var f *os.File f, err = os.Open(asset.Name) if err != nil { return } defer f.Close() body = f continue } if err = checkStatus(201, "uploading release asset", res, err); err != nil { return } break } newAsset := ReleaseAsset{} err = res.Unmarshal(&newAsset) if err != nil { return } doneAssets = append(doneAssets, &newAsset) } return } func (client *Client) DeleteReleaseAsset(asset *ReleaseAsset) (err error) { api, err := client.simpleAPI() if err != nil { return } res, err := api.Delete(asset.APIURL) err = checkStatus(204, "deleting release asset", res, err) return } func (client *Client) DownloadReleaseAsset(url string) (asset io.ReadCloser, err error) { api, err := client.simpleAPI() if err != nil { return } resp, err := api.GetFile(url, "application/octet-stream") if err = checkStatus(200, "downloading asset", resp, err); err != nil { return } return resp.Body, err } type CIStatusResponse struct { State string `json:"state"` Statuses []CIStatus `json:"statuses"` } type CIStatus struct { State string `json:"state"` Context string `json:"context"` TargetURL string `json:"target_url"` } type CheckRunsResponse struct { CheckRuns []CheckRun `json:"check_runs"` } type CheckRun struct { Status string `json:"status"` Conclusion string `json:"conclusion"` Name string `json:"name"` HTMLURL string `json:"html_url"` } func (client *Client) FetchCIStatus(project *Project, sha string) (status *CIStatusResponse, err error) { api, err := client.simpleAPI() if err != nil { return } res, err := api.Get(fmt.Sprintf("repos/%s/%s/commits/%s/status?per_page=100", project.Owner, project.Name, sha)) if err = checkStatus(200, "fetching statuses", res, err); err != nil { return } status = &CIStatusResponse{} if err = res.Unmarshal(status); err != nil { return } sortStatuses := func() { sort.Slice(status.Statuses, func(a, b int) bool { sA := status.Statuses[a] sB := status.Statuses[b] cmp := strings.Compare(strings.ToLower(sA.Context), strings.ToLower(sB.Context)) if cmp == 0 { return strings.Compare(sA.TargetURL, sB.TargetURL) < 0 } return cmp < 0 }) } sortStatuses() res, err = api.GetFile(fmt.Sprintf("repos/%s/%s/commits/%s/check-runs?per_page=100", project.Owner, project.Name, sha), checksType) if err == nil && (res.StatusCode == 403 || res.StatusCode == 404 || res.StatusCode == 422) { return } if err = checkStatus(200, "fetching checks", res, err); err != nil { return } checks := &CheckRunsResponse{} if err = res.Unmarshal(checks); err != nil { return } for _, checkRun := range checks.CheckRuns { state := "pending" if checkRun.Status == "completed" { state = checkRun.Conclusion } checkStatus := CIStatus{ State: state, Context: checkRun.Name, TargetURL: checkRun.HTMLURL, } status.Statuses = append(status.Statuses, checkStatus) } sortStatuses() return } type Repository struct { Name string `json:"name"` FullName string `json:"full_name"` Parent *Repository `json:"parent"` Owner *User `json:"owner"` Private bool `json:"private"` HasWiki bool `json:"has_wiki"` Permissions *RepositoryPermissions `json:"permissions"` HTMLURL string `json:"html_url"` DefaultBranch string `json:"default_branch"` } type RepositoryPermissions struct { Admin bool `json:"admin"` Push bool `json:"push"` Pull bool `json:"pull"` } func (client *Client) ForkRepository(project *Project, params map[string]interface{}) (repo *Repository, err error) { api, err := client.simpleAPI() if err != nil { return } res, err := api.PostJSON(fmt.Sprintf("repos/%s/%s/forks", project.Owner, project.Name), params) if err = checkStatus(202, "creating fork", res, err); err != nil { return } repo = &Repository{} err = res.Unmarshal(repo) return } type Comment struct { ID int `json:"id"` Body string `json:"body"` User *User `json:"user"` CreatedAt time.Time `json:"created_at"` } type Issue struct { Number int `json:"number"` State string `json:"state"` Title string `json:"title"` Body string `json:"body"` User *User `json:"user"` PullRequest *PullRequest `json:"pull_request"` Head *PullRequestSpec `json:"head"` Base *PullRequestSpec `json:"base"` MergeCommitSha string `json:"merge_commit_sha"` MaintainerCanModify bool `json:"maintainer_can_modify"` Draft bool `json:"draft"` Comments int `json:"comments"` Labels []IssueLabel `json:"labels"` Assignees []User `json:"assignees"` Milestone *Milestone `json:"milestone"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` MergedAt time.Time `json:"merged_at"` RequestedReviewers []User `json:"requested_reviewers"` RequestedTeams []Team `json:"requested_teams"` APIURL string `json:"url"` HTMLURL string `json:"html_url"` ClosedBy *User `json:"closed_by"` } type PullRequest Issue type PullRequestSpec struct { Label string `json:"label"` Ref string `json:"ref"` Sha string `json:"sha"` Repo *Repository `json:"repo"` } func (pr *PullRequest) IsSameRepo() bool { return pr.Head != nil && pr.Head.Repo != nil && pr.Head.Repo.Name == pr.Base.Repo.Name && pr.Head.Repo.Owner.Login == pr.Base.Repo.Owner.Login } func (pr *PullRequest) HasRequestedReviewer(name string) bool { for _, user := range pr.RequestedReviewers { if strings.EqualFold(user.Login, name) { return true } } return false } func (pr *PullRequest) HasRequestedTeam(name string) bool { for _, team := range pr.RequestedTeams { if strings.EqualFold(team.Slug, name) { return true } } return false } type IssueLabel struct { Name string `json:"name"` Color string `json:"color"` } type User struct { Login string `json:"login"` } type Team struct { Name string `json:"name"` Slug string `json:"slug"` } type Milestone struct { Number int `json:"number"` Title string `json:"title"` } func (client *Client) FetchIssues(project *Project, filterParams map[string]interface{}, limit int, filter func(*Issue) bool) (issues []Issue, err error) { api, err := client.simpleAPI() if err != nil { return } path := fmt.Sprintf("repos/%s/%s/issues?per_page=%d", project.Owner, project.Name, perPage(limit, 100)) if filterParams != nil { path = addQuery(path, filterParams) } issues = []Issue{} var res *simpleResponse for path != "" { res, err = api.Get(path) if err = checkStatus(200, "fetching issues", res, err); err != nil { return } path = res.Link("next") issuesPage := []Issue{} if err = res.Unmarshal(&issuesPage); err != nil { return } for _, issue := range issuesPage { if filter == nil || filter(&issue) { issues = append(issues, issue) if limit > 0 && len(issues) == limit { path = "" break } } } } return } func (client *Client) FetchIssue(project *Project, number string) (issue *Issue, err error) { api, err := client.simpleAPI() if err != nil { return } res, err := api.Get(fmt.Sprintf("repos/%s/%s/issues/%s", project.Owner, project.Name, number)) if err = checkStatus(200, "fetching issue", res, err); err != nil { return nil, err } issue = &Issue{} err = res.Unmarshal(issue) return } func (client *Client) FetchComments(project *Project, number string) (comments []Comment, err error) { api, err := client.simpleAPI() if err != nil { return } res, err := api.Get(fmt.Sprintf("repos/%s/%s/issues/%s/comments", project.Owner, project.Name, number)) if err = checkStatus(200, "fetching comments for issue", res, err); err != nil { return nil, err } comments = []Comment{} err = res.Unmarshal(&comments) return } func (client *Client) CreateIssue(project *Project, params interface{}) (issue *Issue, err error) { api, err := client.simpleAPI() if err != nil { return } res, err := api.PostJSON(fmt.Sprintf("repos/%s/%s/issues", project.Owner, project.Name), params) if err = checkStatus(201, "creating issue", res, err); err != nil { return } issue = &Issue{} err = res.Unmarshal(issue) return } func (client *Client) UpdateIssue(project *Project, issueNumber int, params map[string]interface{}) (err error) { api, err := client.simpleAPI() if err != nil { return } res, err := api.PatchJSON(fmt.Sprintf("repos/%s/%s/issues/%d", project.Owner, project.Name, issueNumber), params) if err = checkStatus(200, "updating issue", res, err); err != nil { return } res.Body.Close() return } type sortedLabels []IssueLabel func (s sortedLabels) Len() int { return len(s) } func (s sortedLabels) Swap(i, j int) { s[i], s[j] = s[j], s[i] } func (s sortedLabels) Less(i, j int) bool { return strings.Compare(strings.ToLower(s[i].Name), strings.ToLower(s[j].Name)) < 0 } func (client *Client) FetchLabels(project *Project) (labels []IssueLabel, err error) { api, err := client.simpleAPI() if err != nil { return } path := fmt.Sprintf("repos/%s/%s/labels?per_page=100", project.Owner, project.Name) labels = []IssueLabel{} var res *simpleResponse for path != "" { res, err = api.Get(path) if err = checkStatus(200, "fetching labels", res, err); err != nil { return } path = res.Link("next") labelsPage := []IssueLabel{} if err = res.Unmarshal(&labelsPage); err != nil { return } labels = append(labels, labelsPage...) } sort.Sort(sortedLabels(labels)) return } func (client *Client) FetchMilestones(project *Project) (milestones []Milestone, err error) { api, err := client.simpleAPI() if err != nil { return } path := fmt.Sprintf("repos/%s/%s/milestones?per_page=100", project.Owner, project.Name) milestones = []Milestone{} var res *simpleResponse for path != "" { res, err = api.Get(path) if err = checkStatus(200, "fetching milestones", res, err); err != nil { return } path = res.Link("next") milestonesPage := []Milestone{} if err = res.Unmarshal(&milestonesPage); err != nil { return } milestones = append(milestones, milestonesPage...) } return } func (client *Client) GenericAPIRequest(method, path string, data interface{}, headers map[string]string, ttl int) (*simpleResponse, error) { api, err := client.simpleAPI() if err != nil { return nil, err } api.CacheTTL = ttl var body io.Reader switch d := data.(type) { case map[string]interface{}: if method == "GET" { path = addQuery(path, d) } else if len(d) > 0 { json, err := json.Marshal(d) if err != nil { return nil, err } body = bytes.NewBuffer(json) } case io.Reader: body = d } return api.performRequest(method, path, body, func(req *http.Request) { if body != nil { req.Header.Set("Content-Type", "application/json; charset=utf-8") } for key, value := range headers { req.Header.Set(key, value) } }) } // GraphQL facilitates performing a GraphQL request and parsing the response func (client *Client) GraphQL(query string, variables interface{}, data interface{}) error { api, err := client.simpleAPI() if err != nil { return err } payload := map[string]interface{}{ "query": query, "variables": variables, } resp, err := api.PostJSON("graphql", payload) if err = checkStatus(200, "performing GraphQL", resp, err); err != nil { return err } responseData := struct { Data interface{} Errors []struct { Message string } }{ Data: data, } err = resp.Unmarshal(&responseData) if err != nil { return err } if len(responseData.Errors) > 0 { messages := []string{} for _, e := range responseData.Errors { messages = append(messages, e.Message) } return fmt.Errorf("API error: %s", strings.Join(messages, "; ")) } return nil } func (client *Client) CurrentUser() (user *User, err error) { api, err := client.simpleAPI() if err != nil { return } res, err := api.Get("user") if err = checkStatus(200, "getting current user", res, err); err != nil { return } user = &User{} err = res.Unmarshal(user) return } type AuthorizationEntry struct { Token string `json:"token"` } func isToken(api *simpleClient, password string) bool { api.PrepareRequest = func(req *http.Request) { req.Header.Set("Authorization", "token "+password) } res, _ := api.Get("user") if res != nil && res.StatusCode == 200 { return true } return false } func (client *Client) FindOrCreateToken(user, password, twoFactorCode string) (token string, err error) { api := client.apiClient() if len(password) >= 40 && isToken(api, password) { return password, nil } params := map[string]interface{}{ "scopes": []string{"repo", "gist"}, "note_url": OAuthAppURL, } api.PrepareRequest = func(req *http.Request) { req.SetBasicAuth(user, password) if twoFactorCode != "" { req.Header.Set("X-GitHub-OTP", twoFactorCode) } } count := 1 maxTries := 9 for { params["note"], err = authTokenNote(count) if err != nil { return } res, postErr := api.PostJSON("authorizations", params) if postErr != nil { err = postErr break } if res.StatusCode == 201 { auth := &AuthorizationEntry{} if err = res.Unmarshal(auth); err != nil { return } token = auth.Token break } else if res.StatusCode == 422 && count < maxTries { count++ } else { errInfo, e := res.ErrorInfo() if e == nil { err = errInfo } else { err = e } return } } return } func (client *Client) ensureAccessToken() error { if client.Host.AccessToken == "" { host, err := CurrentConfig().PromptForHost(client.Host.Host) if err != nil { return err } client.Host = host } return nil } func (client *Client) simpleAPI() (c *simpleClient, err error) { err = client.ensureAccessToken() if err != nil { return } if client.cachedClient != nil { c = client.cachedClient return } c = client.apiClient() c.PrepareRequest = func(req *http.Request) { clientDomain := normalizeHost(client.Host.Host) if strings.HasPrefix(clientDomain, "api.github.") { clientDomain = strings.TrimPrefix(clientDomain, "api.") } requestHost := strings.ToLower(req.URL.Host) if requestHost == clientDomain || strings.HasSuffix(requestHost, "."+clientDomain) { req.Header.Set("Authorization", "token "+client.Host.AccessToken) } } client.cachedClient = c return } func (client *Client) apiClient() *simpleClient { unixSocket := os.ExpandEnv(client.Host.UnixSocket) httpClient := newHTTPClient(os.Getenv("HUB_TEST_HOST"), os.Getenv("HUB_VERBOSE") != "", unixSocket) apiRoot := client.absolute(normalizeHost(client.Host.Host)) if !strings.HasPrefix(apiRoot.Host, "api.github.") { apiRoot.Path = "/api/v3/" } return &simpleClient{ httpClient: httpClient, rootURL: apiRoot, } } func (client *Client) absolute(host string) *url.URL { u, err := url.Parse("https://" + host + "/") if err != nil { panic(err) } else if client.Host != nil && client.Host.Protocol != "" { u.Scheme = client.Host.Protocol } return u } func (client *Client) FetchGist(id string) (gist *Gist, err error) { api, err := client.simpleAPI() if err != nil { return } response, err := api.Get(fmt.Sprintf("gists/%s", id)) if err = checkStatus(200, "getting gist", response, err); err != nil { return } response.Unmarshal(&gist) return } func (client *Client) CreateGist(filenames []string, public bool) (gist *Gist, err error) { api, err := client.simpleAPI() if err != nil { return } files := map[string]GistFile{} var basename string var content []byte var gf GistFile for _, file := range filenames { if file == "-" { content, err = ioutil.ReadAll(os.Stdin) basename = "gistfile1.txt" } else { content, err = ioutil.ReadFile(file) basename = path.Base(file) } if err != nil { return } gf = GistFile{Content: string(content)} files[basename] = gf } g := Gist{ Files: files, Public: public, } res, err := api.PostJSON("gists", &g) if err = checkStatus(201, "creating gist", res, err); err != nil { return } err = res.Unmarshal(&gist) return } func normalizeHost(host string) string { if host == "" { return GitHubHost } else if strings.EqualFold(host, GitHubHost) { return "api.github.com" } else if strings.EqualFold(host, "github.localhost") { return "api.github.localhost" } else { return strings.ToLower(host) } } func reverseNormalizeHost(host string) string { switch host { case "api.github.com": return GitHubHost case "api.github.localhost": return "github.localhost" default: return host } } func checkStatus(expectedStatus int, action string, response *simpleResponse, err error) error { if err != nil { errStr := err.Error() if urlErr, isURLErr := err.(*url.Error); isURLErr { errStr = fmt.Sprintf("%s %s: %s", urlErr.Op, urlErr.URL, urlErr.Err) } return fmt.Errorf("Error %s: %s", action, errStr) } else if response.StatusCode != expectedStatus { errInfo, err := response.ErrorInfo() if err != nil { return fmt.Errorf("Error %s: %s (HTTP %d)", action, err.Error(), response.StatusCode) } return FormatError(action, errInfo) } return nil } // FormatError annotates an HTTP response error with user-friendly messages func FormatError(action string, err error) error { if e, ok := err.(*errorInfo); ok { return formatError(action, e) } return err } func formatError(action string, e *errorInfo) error { var reason string if s := strings.SplitN(e.Response.Status, " ", 2); len(s) >= 2 { reason = strings.TrimSpace(s[1]) } errStr := fmt.Sprintf("Error %s: %s (HTTP %d)", action, reason, e.Response.StatusCode) var errorSentences []string for _, err := range e.Errors { switch err.Code { case "custom": errorSentences = append(errorSentences, err.Message) case "missing_field": errorSentences = append(errorSentences, fmt.Sprintf("Missing field: \"%s\"", err.Field)) case "already_exists": errorSentences = append(errorSentences, fmt.Sprintf("Duplicate value for \"%s\"", err.Field)) case "invalid": errorSentences = append(errorSentences, fmt.Sprintf("Invalid value for \"%s\"", err.Field)) case "unauthorized": errorSentences = append(errorSentences, fmt.Sprintf("Not allowed to change field \"%s\"", err.Field)) } } var errorMessage string if len(errorSentences) > 0 { errorMessage = strings.Join(errorSentences, "\n") } else { errorMessage = e.Message if action == "getting current user" && e.Message == "Resource not accessible by integration" { errorMessage = errorMessage + "\nYou must specify GITHUB_USER via environment variable." } } if errorMessage != "" { errStr = fmt.Sprintf("%s\n%s", errStr, errorMessage) } if ssoErr := ValidateGitHubSSO(e.Response); ssoErr != nil { return fmt.Errorf("%s\n%s", errStr, ssoErr) } if scopeErr := ValidateSufficientOAuthScopes(e.Response); scopeErr != nil { return fmt.Errorf("%s\n%s", errStr, scopeErr) } return errors.New(errStr) } // ValidateGitHubSSO checks for the challenge via `X-Github-Sso` header func ValidateGitHubSSO(res *http.Response) error { if res.StatusCode != 403 { return nil } sso := res.Header.Get("X-Github-Sso") if !strings.HasPrefix(sso, "required; url=") { return nil } url := sso[strings.IndexByte(sso, '=')+1:] return fmt.Errorf("You must authorize your token to access this organization:\n%s", url) } // ValidateSufficientOAuthScopes warns about insufficient OAuth scopes func ValidateSufficientOAuthScopes(res *http.Response) error { if res.StatusCode != 404 && res.StatusCode != 403 { return nil } needScopes := newScopeSet(res.Header.Get("X-Accepted-Oauth-Scopes")) if len(needScopes) == 0 && isGistWrite(res.Request) { // compensate for a GitHub bug: gist APIs omit proper `X-Accepted-Oauth-Scopes` in responses needScopes = newScopeSet("gist") } haveScopes := newScopeSet(res.Header.Get("X-Oauth-Scopes")) if len(needScopes) == 0 || needScopes.Intersects(haveScopes) { return nil } return fmt.Errorf("Your access token may have insufficient scopes. Visit %s://%s/settings/tokens\n"+ "to edit the 'hub' token and enable one of the following scopes: %s", res.Request.URL.Scheme, reverseNormalizeHost(res.Request.Host), needScopes) } func isGistWrite(req *http.Request) bool { if req.Method == "GET" { return false } path := strings.TrimPrefix(req.URL.Path, "/v3") return strings.HasPrefix(path, "/gists") } type scopeSet map[string]struct{} func (s scopeSet) String() string { scopes := make([]string, 0, len(s)) for scope := range s { scopes = append(scopes, scope) } sort.Strings(scopes) return strings.Join(scopes, ", ") } func (s scopeSet) Intersects(other scopeSet) bool { for scope := range s { if _, found := other[scope]; found { return true } } return false } func newScopeSet(s string) scopeSet { scopes := scopeSet{} for _, s := range strings.SplitN(s, ",", -1) { if s = strings.TrimSpace(s); s != "" { scopes[s] = struct{}{} } } return scopes } func authTokenNote(num int) (string, error) { n := os.Getenv("USER") if n == "" { n = os.Getenv("USERNAME") } if n == "" { whoami := exec.Command("whoami") whoamiOut, err := whoami.Output() if err != nil { return "", err } n = strings.TrimSpace(string(whoamiOut)) } h, err := os.Hostname() if err != nil { return "", err } if num > 1 { return fmt.Sprintf("hub for %s@%s %d", n, h, num), nil } return fmt.Sprintf("hub for %s@%s", n, h), nil } func perPage(limit, max int) int { if limit > 0 { limit = limit + (limit / 2) if limit < max { return limit } } return max } func addQuery(path string, params map[string]interface{}) string { if len(params) == 0 { return path } query := url.Values{} for key, value := range params { switch v := value.(type) { case string: query.Add(key, v) case nil: query.Add(key, "") case int: query.Add(key, fmt.Sprintf("%d", v)) case bool: query.Add(key, fmt.Sprintf("%v", v)) } } sep := "?" if strings.Contains(path, sep) { sep = "&" } return path + sep + query.Encode() } ================================================ FILE: github/client_test.go ================================================ package github import ( "fmt" "net/http" "regexp" "testing" "github.com/github/hub/v2/internal/assert" ) func TestClient_FormatError(t *testing.T) { e := &errorInfo{ Response: &http.Response{ StatusCode: 401, Status: "401 Not Found", }, } err := FormatError("action", e) assert.Equal(t, "Error action: Not Found (HTTP 401)", fmt.Sprintf("%s", err)) e = &errorInfo{ Response: &http.Response{ StatusCode: 422, Status: "422 Unprocessable Entity", }, Message: "error message", } err = FormatError("action", e) assert.Equal(t, "Error action: Unprocessable Entity (HTTP 422)\nerror message", fmt.Sprintf("%s", err)) } func TestAuthTokenNote(t *testing.T) { note, err := authTokenNote(1) assert.Equal(t, nil, err) reg := regexp.MustCompile("hub for (.+)@(.+)") assert.T(t, reg.MatchString(note)) note, err = authTokenNote(2) assert.Equal(t, nil, err) reg = regexp.MustCompile("hub for (.+)@(.+) 2") assert.T(t, reg.MatchString(note)) } ================================================ FILE: github/config.go ================================================ package github import ( "bufio" "fmt" "io/ioutil" "net/url" "os" "os/signal" "path/filepath" "strconv" "strings" "syscall" "github.com/github/hub/v2/ui" "github.com/github/hub/v2/utils" "github.com/mitchellh/go-homedir" "golang.org/x/crypto/ssh/terminal" ) type yamlHost struct { User string `yaml:"user"` OAuthToken string `yaml:"oauth_token"` Protocol string `yaml:"protocol"` UnixSocket string `yaml:"unix_socket,omitempty"` } type Host struct { Host string `toml:"host"` User string `toml:"user"` AccessToken string `toml:"access_token"` Protocol string `toml:"protocol"` UnixSocket string `toml:"unix_socket,omitempty"` } type Config struct { Hosts []*Host `toml:"hosts"` stdinScanner *bufio.Scanner } func (c *Config) PromptForHost(host string) (h *Host, err error) { token := c.DetectToken() tokenFromEnv := token != "" if host != GitHubHost { if _, e := url.Parse("https://" + host); e != nil { err = fmt.Errorf("invalid hostname: %q", host) return } } h = c.Find(host) if h != nil { if h.User == "" { utils.Check(CheckWriteable(configsFile())) // User is missing from the config: this is a broken config probably // because it was created with an old (broken) version of hub. Let's fix // it now. See issue #1007 for details. user := c.PromptForUser(host) if user == "" { utils.Check(fmt.Errorf("missing user")) } h.User = user err := newConfigService().Save(configsFile(), c) utils.Check(err) } if tokenFromEnv { h.AccessToken = token } else { return } } else { h = &Host{ Host: host, AccessToken: token, Protocol: "https", } c.Hosts = append(c.Hosts, h) } client := NewClientWithHost(h) if !tokenFromEnv { utils.Check(CheckWriteable(configsFile())) err = c.authorizeClient(client, host) if err != nil { return } } userFromEnv := os.Getenv("GITHUB_USER") repoFromEnv := os.Getenv("GITHUB_REPOSITORY") if userFromEnv == "" && repoFromEnv != "" { repoParts := strings.SplitN(repoFromEnv, "/", 2) if len(repoParts) > 0 { userFromEnv = repoParts[0] } } if tokenFromEnv && userFromEnv != "" { h.User = userFromEnv } else { var currentUser *User currentUser, err = client.CurrentUser() if err != nil { return } h.User = currentUser.Login } if !tokenFromEnv { err = newConfigService().Save(configsFile(), c) } return } func (c *Config) authorizeClient(client *Client, host string) (err error) { user := c.PromptForUser(host) pass := c.PromptForPassword(host, user) var code, token string for { token, err = client.FindOrCreateToken(user, pass, code) if err == nil { break } if ae, ok := err.(*errorInfo); ok && strings.HasPrefix(ae.Response.Header.Get("X-GitHub-OTP"), "required;") { if code != "" { ui.Errorln("warning: invalid two-factor code") } code = c.PromptForOTP() } else { break } } if err == nil { client.Host.AccessToken = token } return } func (c *Config) DetectToken() string { return os.Getenv("GITHUB_TOKEN") } func (c *Config) PromptForUser(host string) (user string) { user = os.Getenv("GITHUB_USER") if user != "" { return } ui.Printf("%s username: ", host) user = c.scanLine() return } func (c *Config) PromptForPassword(host, user string) (pass string) { pass = os.Getenv("GITHUB_PASSWORD") if pass != "" { return } ui.Printf("%s password for %s (never stored): ", host, user) if ui.IsTerminal(os.Stdin) { if password, err := getPassword(); err == nil { pass = password } } else { pass = c.scanLine() } return } func (c *Config) PromptForOTP() string { fmt.Print("two-factor authentication code: ") return c.scanLine() } func (c *Config) scanLine() string { if c.stdinScanner == nil { c.stdinScanner = bufio.NewScanner(os.Stdin) } var line string scanner := c.stdinScanner if scanner.Scan() { line = scanner.Text() } utils.Check(scanner.Err()) return line } func getPassword() (string, error) { stdin := int(syscall.Stdin) initialTermState, err := terminal.GetState(stdin) if err != nil { return "", err } c := make(chan os.Signal, 1) signal.Notify(c, os.Interrupt, syscall.SIGTERM) go func() { s := <-c terminal.Restore(stdin, initialTermState) switch sig := s.(type) { case syscall.Signal: if int(sig) == 2 { fmt.Println("^C") } } os.Exit(1) }() passBytes, err := terminal.ReadPassword(stdin) if err != nil { return "", err } signal.Stop(c) fmt.Print("\n") return string(passBytes), nil } func (c *Config) Find(host string) *Host { for _, h := range c.Hosts { if h.Host == host { return h } } return nil } func (c *Config) selectHost() *Host { options := len(c.Hosts) if options == 1 { return c.Hosts[0] } prompt := "Select host:\n" for idx, host := range c.Hosts { prompt += fmt.Sprintf(" %d. %s\n", idx+1, host.Host) } prompt += fmt.Sprint("> ") ui.Printf(prompt) index := c.scanLine() i, err := strconv.Atoi(index) if err != nil || i < 1 || i > options { utils.Check(fmt.Errorf("Error: must enter a number [1-%d]", options)) } return c.Hosts[i-1] } var defaultConfigsFile string func configsFile() string { if configFromEnv := os.Getenv("HUB_CONFIG"); configFromEnv != "" { return configFromEnv } if defaultConfigsFile == "" { var err error defaultConfigsFile, err = determineConfigLocation() utils.Check(err) } return defaultConfigsFile } func homeConfig() (string, error) { home, err := homedir.Dir() if err != nil { return "", err } return filepath.Join(home, ".config"), nil } func determineConfigLocation() (string, error) { var err error xdgHome := os.Getenv("XDG_CONFIG_HOME") configDir := xdgHome if configDir == "" { if configDir, err = homeConfig(); err != nil { return "", err } } xdgDirs := os.Getenv("XDG_CONFIG_DIRS") if xdgDirs == "" { xdgDirs = "/etc/xdg" } searchDirs := append([]string{configDir}, strings.Split(xdgDirs, ":")...) for _, dir := range searchDirs { filename := filepath.Join(dir, "hub") if _, err := os.Stat(filename); err == nil { return filename, nil } } configFile := filepath.Join(configDir, "hub") if configDir == xdgHome { if homeDir, _ := homeConfig(); homeDir != "" { legacyConfig := filepath.Join(homeDir, "hub") if _, err = os.Stat(legacyConfig); err == nil { ui.Errorf("Notice: config file found but not respected at: %s\n", legacyConfig) ui.Errorf("You might want to move it to `%s' to avoid re-authenticating.\n", configFile) } } } return configFile, nil } var currentConfig *Config var configLoadedFrom = "" func CurrentConfig() *Config { filename := configsFile() if configLoadedFrom != filename { currentConfig = &Config{} newConfigService().Load(filename, currentConfig) configLoadedFrom = filename } return currentConfig } func (c *Config) DefaultHost() (host *Host, err error) { if GitHubHostEnv != "" { host, err = c.PromptForHost(GitHubHostEnv) } else if len(c.Hosts) > 0 { host = c.selectHost() // HACK: forces host to inherit GITHUB_TOKEN if applicable host, err = c.PromptForHost(host.Host) } else { host, err = c.PromptForHost(DefaultGitHubHost()) } return } func (c *Config) DefaultHostNoPrompt() (*Host, error) { if GitHubHostEnv != "" { return c.PromptForHost(GitHubHostEnv) } else if len(c.Hosts) > 0 { host := c.Hosts[0] // HACK: forces host to inherit GITHUB_TOKEN if applicable return c.PromptForHost(host.Host) } else { return c.PromptForHost(GitHubHost) } } // CheckWriteable checks if config file is writeable. This should // be called before asking for credentials and only if current // operation needs to update the file. See issue #1314 for details. func CheckWriteable(filename string) error { // Check if file exists already. if it doesn't, we will delete it after // checking for writeabilty fileExistsAlready := false if _, err := os.Stat(filename); err == nil { fileExistsAlready = true } err := os.MkdirAll(filepath.Dir(filename), 0771) if err != nil { return err } w, err := os.OpenFile(filename, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0600) if err != nil { return err } w.Close() if !fileExistsAlready { err := os.Remove(filename) if err != nil { return err } } return nil } // CreateTestConfigs is public for testing purposes func CreateTestConfigs(user, token string) *Config { f, _ := ioutil.TempFile("", "test-config") os.Setenv("HUB_CONFIG", f.Name()) host := &Host{ User: "jingweno", AccessToken: "123", Host: GitHubHost, } c := &Config{Hosts: []*Host{host}} err := newConfigService().Save(f.Name(), c) if err != nil { panic(err) } return c } ================================================ FILE: github/config_decoder.go ================================================ package github import ( "fmt" "io" "io/ioutil" "github.com/BurntSushi/toml" "gopkg.in/yaml.v2" ) type configDecoder interface { Decode(r io.Reader, c *Config) error } type tomlConfigDecoder struct { } func (t *tomlConfigDecoder) Decode(r io.Reader, c *Config) error { _, err := toml.DecodeReader(r, c) return err } type yamlConfigDecoder struct { } func (y *yamlConfigDecoder) Decode(r io.Reader, c *Config) error { d, err := ioutil.ReadAll(r) if err != nil { return err } yc := yaml.MapSlice{} err = yaml.Unmarshal(d, &yc) if err != nil { return err } for _, hostEntry := range yc { v, ok := hostEntry.Value.([]interface{}) if !ok { return fmt.Errorf("value of host entry is must be array but got %#v", hostEntry.Value) } if len(v) < 1 { continue } hostName, ok := hostEntry.Key.(string) if !ok { return fmt.Errorf("host name is must be string but got %#v", hostEntry.Key) } host := &Host{Host: hostName} for _, prop := range v[0].(yaml.MapSlice) { propName, ok := prop.Key.(string) if !ok { return fmt.Errorf("property name is must be string but got %#v", prop.Key) } switch propName { case "user": host.User, ok = prop.Value.(string) case "oauth_token": host.AccessToken, ok = prop.Value.(string) case "protocol": host.Protocol, ok = prop.Value.(string) case "unix_socket": host.UnixSocket, ok = prop.Value.(string) } if !ok { return fmt.Errorf("%s is must be string but got %#v", propName, prop.Value) } } c.Hosts = append(c.Hosts, host) } return nil } ================================================ FILE: github/config_encoder.go ================================================ package github import ( "io" "github.com/BurntSushi/toml" "gopkg.in/yaml.v2" ) type configEncoder interface { Encode(w io.Writer, c *Config) error } type tomlConfigEncoder struct { } func (t *tomlConfigEncoder) Encode(w io.Writer, c *Config) error { enc := toml.NewEncoder(w) return enc.Encode(c) } type yamlConfigEncoder struct { } func (y *yamlConfigEncoder) Encode(w io.Writer, c *Config) error { yc := yaml.MapSlice{} for _, h := range c.Hosts { yc = append(yc, yaml.MapItem{ Key: h.Host, Value: []yamlHost{ { User: h.User, OAuthToken: h.AccessToken, Protocol: h.Protocol, UnixSocket: h.UnixSocket, }, }, }) } d, err := yaml.Marshal(yc) if err != nil { return err } n, err := w.Write(d) if err == nil && n < len(d) { err = io.ErrShortWrite } return err } ================================================ FILE: github/config_service.go ================================================ package github import ( "os" "path/filepath" ) func newConfigService() *configService { return &configService{ Encoder: &yamlConfigEncoder{}, Decoder: &yamlConfigDecoder{}, } } type configService struct { Encoder configEncoder Decoder configDecoder } func (s *configService) Save(filename string, c *Config) error { err := os.MkdirAll(filepath.Dir(filename), 0771) if err != nil { return err } w, err := os.OpenFile(filename, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600) if err != nil { return err } defer w.Close() return s.Encoder.Encode(w, c) } func (s *configService) Load(filename string, c *Config) error { r, err := os.Open(filename) if err != nil { return err } defer r.Close() return s.Decoder.Decode(r, c) } ================================================ FILE: github/config_service_test.go ================================================ package github import ( "io/ioutil" "os" "strings" "testing" "github.com/github/hub/v2/fixtures" "github.com/github/hub/v2/internal/assert" ) func TestConfigService_TomlLoad(t *testing.T) { testConfig := fixtures.SetupTomlTestConfig() defer testConfig.TearDown() cc := &Config{} cs := &configService{ Encoder: &tomlConfigEncoder{}, Decoder: &tomlConfigDecoder{}, } err := cs.Load(testConfig.Path, cc) assert.Equal(t, nil, err) assert.Equal(t, 1, len(cc.Hosts)) host := cc.Hosts[0] assert.Equal(t, "github.com", host.Host) assert.Equal(t, "jingweno", host.User) assert.Equal(t, "123", host.AccessToken) assert.Equal(t, "http", host.Protocol) } func TestConfigService_TomlLoad_UnixSocket(t *testing.T) { testConfigUnixSocket := fixtures.SetupTomlTestConfigWithUnixSocket() defer testConfigUnixSocket.TearDown() cc := &Config{} cs := &configService{ Encoder: &tomlConfigEncoder{}, Decoder: &tomlConfigDecoder{}, } err := cs.Load(testConfigUnixSocket.Path, cc) assert.Equal(t, nil, err) assert.Equal(t, 1, len(cc.Hosts)) host := cc.Hosts[0] assert.Equal(t, "github.com", host.Host) assert.Equal(t, "jingweno", host.User) assert.Equal(t, "123", host.AccessToken) assert.Equal(t, "http", host.Protocol) assert.Equal(t, "/tmp/go.sock", host.UnixSocket) } func TestConfigService_YamlLoad(t *testing.T) { testConfig := fixtures.SetupTestConfigs() defer testConfig.TearDown() cc := &Config{} cs := &configService{ Encoder: &yamlConfigEncoder{}, Decoder: &yamlConfigDecoder{}, } err := cs.Load(testConfig.Path, cc) assert.Equal(t, nil, err) assert.Equal(t, 1, len(cc.Hosts)) host := cc.Hosts[0] assert.Equal(t, "github.com", host.Host) assert.Equal(t, "jingweno", host.User) assert.Equal(t, "123", host.AccessToken) assert.Equal(t, "http", host.Protocol) } func TestConfigService_YamlLoad_Unix_Socket(t *testing.T) { testConfigUnixSocket := fixtures.SetupTestConfigsWithUnixSocket() defer testConfigUnixSocket.TearDown() cc := &Config{} cs := &configService{ Encoder: &yamlConfigEncoder{}, Decoder: &yamlConfigDecoder{}, } err := cs.Load(testConfigUnixSocket.Path, cc) assert.Equal(t, nil, err) assert.Equal(t, 1, len(cc.Hosts)) host := cc.Hosts[0] assert.Equal(t, "github.com", host.Host) assert.Equal(t, "jingweno", host.User) assert.Equal(t, "123", host.AccessToken) assert.Equal(t, "http", host.Protocol) assert.Equal(t, "/tmp/go.sock", host.UnixSocket) } func TestConfigService_YamlLoad_Invalid_HostName(t *testing.T) { testConfigInvalidHostName := fixtures.SetupTestConfigsInvalidHostName() defer testConfigInvalidHostName.TearDown() cc := &Config{} cs := &configService{ Encoder: &yamlConfigEncoder{}, Decoder: &yamlConfigDecoder{}, } err := cs.Load(testConfigInvalidHostName.Path, cc) assert.NotEqual(t, nil, err) assert.Equal(t, "host name is must be string but got 123", err.Error()) } func TestConfigService_YamlLoad_Invalid_HostEntry(t *testing.T) { testConfigInvalidHostEntry := fixtures.SetupTestConfigsInvalidHostEntry() defer testConfigInvalidHostEntry.TearDown() cc := &Config{} cs := &configService{ Encoder: &yamlConfigEncoder{}, Decoder: &yamlConfigDecoder{}, } err := cs.Load(testConfigInvalidHostEntry.Path, cc) assert.NotEqual(t, nil, err) assert.Equal(t, "value of host entry is must be array but got \"hello\"", err.Error()) } func TestConfigService_YamlLoad_Invalid_PropertyValue(t *testing.T) { testConfigInvalidPropertyValue := fixtures.SetupTestConfigsInvalidPropertyValue() defer testConfigInvalidPropertyValue.TearDown() cc := &Config{} cs := &configService{ Encoder: &yamlConfigEncoder{}, Decoder: &yamlConfigDecoder{}, } err := cs.Load(testConfigInvalidPropertyValue.Path, cc) assert.NotEqual(t, nil, err) assert.Equal(t, "user is must be string but got ", err.Error()) } func TestConfigService_TomlSave(t *testing.T) { file, _ := ioutil.TempFile("", "test-gh-config-") defer os.RemoveAll(file.Name()) host := &Host{ Host: "github.com", User: "jingweno", AccessToken: "123", Protocol: "https", } c := &Config{Hosts: []*Host{host}} cs := &configService{ Encoder: &tomlConfigEncoder{}, Decoder: &tomlConfigDecoder{}, } err := cs.Save(file.Name(), c) assert.Equal(t, nil, err) b, _ := ioutil.ReadFile(file.Name()) content := `[[hosts]] host = "github.com" user = "jingweno" access_token = "123" protocol = "https"` assert.Equal(t, content, strings.TrimSpace(string(b))) } func TestConfigService_TomlSave_UnixSocket(t *testing.T) { file, _ := ioutil.TempFile("", "test-gh-config-") defer os.RemoveAll(file.Name()) host := &Host{ Host: "github.com", User: "jingweno", AccessToken: "123", Protocol: "https", UnixSocket: "/tmp/go.sock", } c := &Config{Hosts: []*Host{host}} cs := &configService{ Encoder: &tomlConfigEncoder{}, Decoder: &tomlConfigDecoder{}, } err := cs.Save(file.Name(), c) assert.Equal(t, nil, err) b, _ := ioutil.ReadFile(file.Name()) content := `[[hosts]] host = "github.com" user = "jingweno" access_token = "123" protocol = "https" unix_socket = "/tmp/go.sock"` assert.Equal(t, content, strings.TrimSpace(string(b))) } func TestConfigService_YamlSave(t *testing.T) { file, _ := ioutil.TempFile("", "test-gh-config-") defer os.RemoveAll(file.Name()) host := &Host{ Host: "github.com", User: "jingweno", AccessToken: "123", Protocol: "https", } c := &Config{Hosts: []*Host{host}} cs := &configService{ Encoder: &yamlConfigEncoder{}, Decoder: &yamlConfigDecoder{}, } err := cs.Save(file.Name(), c) assert.Equal(t, nil, err) b, _ := ioutil.ReadFile(file.Name()) content := `github.com: - user: jingweno oauth_token: "123" protocol: https` assert.Equal(t, content, strings.TrimSpace(string(b))) } func TestConfigService_YamlSave_UnixSocket(t *testing.T) { file, _ := ioutil.TempFile("", "test-gh-config-") defer os.RemoveAll(file.Name()) host := &Host{ Host: "github.com", User: "jingweno", AccessToken: "123", Protocol: "https", UnixSocket: "/tmp/go.sock", } c := &Config{Hosts: []*Host{host}} cs := &configService{ Encoder: &yamlConfigEncoder{}, Decoder: &yamlConfigDecoder{}, } err := cs.Save(file.Name(), c) assert.Equal(t, nil, err) b, _ := ioutil.ReadFile(file.Name()) content := `github.com: - user: jingweno oauth_token: "123" protocol: https unix_socket: /tmp/go.sock` assert.Equal(t, content, strings.TrimSpace(string(b))) } ================================================ FILE: github/crash_report.go ================================================ package github import ( "bufio" "bytes" "errors" "fmt" "os" "reflect" "runtime" "strings" "github.com/github/hub/v2/git" "github.com/github/hub/v2/ui" "github.com/github/hub/v2/utils" "github.com/github/hub/v2/version" ) const ( hubReportCrashConfig = "hub.reportCrash" hubProjectOwner = "github" hubProjectName = "hub" ) func CaptureCrash() { if rec := recover(); rec != nil { switch err := rec.(type) { case error: reportCrash(err) case string: reportCrash(errors.New(err)) default: return } os.Exit(1) } } func reportCrash(err error) { buf := make([]byte, 10000) runtime.Stack(buf, false) stack := formatStack(buf) ui.Errorf("%v\n\n", err) ui.Errorln(stack) isTerm := ui.IsTerminal(os.Stdin) && ui.IsTerminal(os.Stdout) if !isTerm || reportCrashConfig() == "never" { return } ui.Print("Would you like to open an issue? ([y]es / [N]o / n[e]ver): ") var confirm string prompt := bufio.NewScanner(os.Stdin) if prompt.Scan() { confirm = prompt.Text() } if prompt.Err() != nil { return } if isOption(confirm, "y", "yes") { report(err, stack) } else if isOption(confirm, "e", "never") { git.SetGlobalConfig(hubReportCrashConfig, "never") } } func isOption(confirm, short, long string) bool { return strings.EqualFold(confirm, short) || strings.EqualFold(confirm, long) } func report(reportedError error, stack string) { title, body, err := reportTitleAndBody(reportedError, stack) utils.Check(err) project := NewProject(hubProjectOwner, hubProjectName, GitHubHost) gh := NewClient(project.Host) params := map[string]interface{}{ "title": title, "body": body, "labels": []string{"Crash Report"}, } issue, err := gh.CreateIssue(project, params) utils.Check(err) ui.Println(issue.HTMLURL) } const crashReportTmpl = "Crash report - %v\n\n" + "Error (%s): `%v`\n\n" + "Stack:\n\n```\n%s\n```\n\n" + "Runtime:\n\n```\n%s\n```\n\n" + "Version:\n\n```\n%s\nhub version %s\n```\n" func reportTitleAndBody(reportedError error, stack string) (title, body string, err error) { errType := reflect.TypeOf(reportedError).String() gitVersion, gitErr := git.Version() if gitErr != nil { gitVersion = "git unavailable!" } message := fmt.Sprintf( crashReportTmpl, reportedError, errType, reportedError, stack, runtimeInfo(), gitVersion, version.Version, ) messageBuilder := &MessageBuilder{ Filename: "CRASH_REPORT", Title: "crash report", Message: message, Edit: true, } messageBuilder.AddCommentedSection(`Creating crash report: This information will be posted as a new issue under github/hub. We're NOT including any information about the command that you were executing, but knowing a little bit more about it would really help us to solve this problem. Feel free to modify the title and the description for this issue.`) title, body, err = messageBuilder.Extract() if err != nil { return } defer messageBuilder.Cleanup() return } func runtimeInfo() string { return fmt.Sprintf("GOOS: %s\nGOARCH: %s", runtime.GOOS, runtime.GOARCH) } func formatStack(buf []byte) string { buf = bytes.Trim(buf, "\x00") stack := strings.Split(string(buf), "\n") stack = append(stack[0:1], stack[5:]...) return strings.Join(stack, "\n") } func reportCrashConfig() (opt string) { opt = os.Getenv("HUB_REPORT_CRASH") if opt == "" { opt, _ = git.GlobalConfig(hubReportCrashConfig) } return } ================================================ FILE: github/crash_report_test.go ================================================ package github import ( "testing" "github.com/github/hub/v2/internal/assert" ) func TestStackRemoveSelfAndPanic(t *testing.T) { actual := `goroutine 1 [running]: runtime.panic(0x2bca00, 0x665b8a) /usr/local/go/src/pkg/runtime/panic.c:266 +0xb6 github.com/jingweno/gh/github.ReportCrash(0xc2000b5000, 0xc2000b49c0) /Users/calavera/github/go/src/github.com/jingweno/gh/github/crash_report.go:16 +0x97 github.com/jingweno/gh/commands.create(0x47f8a0, 0xc2000cf770) /Users/calavera/github/go/src/github.com/jingweno/gh/commands/create.go:54 +0x63 github.com/jingweno/gh/commands.(*Runner).Execute(0xc200094640, 0xc200094640, 0x21, 0xc2000b0a40) /Users/calavera/github/go/src/github.com/jingweno/gh/commands/runner.go:72 +0x3b7 main.main() /Users/calavera/github/go/src/github.com/jingweno/gh/main.go:10 +0xad` expected := `goroutine 1 [running]: github.com/jingweno/gh/commands.create(0x47f8a0, 0xc2000cf770) /Users/calavera/github/go/src/github.com/jingweno/gh/commands/create.go:54 +0x63 github.com/jingweno/gh/commands.(*Runner).Execute(0xc200094640, 0xc200094640, 0x21, 0xc2000b0a40) /Users/calavera/github/go/src/github.com/jingweno/gh/commands/runner.go:72 +0x3b7 main.main() /Users/calavera/github/go/src/github.com/jingweno/gh/main.go:10 +0xad` s := formatStack([]byte(actual)) assert.Equal(t, expected, s) } ================================================ FILE: github/editor.go ================================================ package github import ( "bufio" "bytes" "fmt" "io/ioutil" "os" "path/filepath" "regexp" "strings" "github.com/github/hub/v2/cmd" "github.com/github/hub/v2/git" "github.com/kballard/go-shellquote" ) const Scissors = "------------------------ >8 ------------------------" func NewEditor(filename, topic, message string) (editor *Editor, err error) { gitDir, err := git.Dir() if err != nil { return } messageFile := filepath.Join(gitDir, filename) program, err := git.Editor() if err != nil { return } cs, err := git.CommentChar(message) if err != nil { return } editor = &Editor{ Program: program, Topic: topic, File: messageFile, Message: message, CS: cs, openEditor: openTextEditor, } return } type Editor struct { Program string Topic string File string Message string CS string addedFirstComment bool openEditor func(program, file string) error } func (e *Editor) AddCommentedSection(text string) { if !e.addedFirstComment { scissors := e.CS + " " + Scissors + "\n" scissors += e.CS + " Do not modify or remove the line above.\n" scissors += e.CS + " Everything below it will be ignored.\n" e.Message = e.Message + "\n" + scissors e.addedFirstComment = true } e.Message = e.Message + "\n" + text } func (e *Editor) DeleteFile() error { return os.Remove(e.File) } func (e *Editor) EditContent() (content string, err error) { b, err := e.openAndEdit() if err != nil { return } b = bytes.TrimSpace(b) reader := bytes.NewReader(b) scanner := bufio.NewScanner(reader) unquotedLines := []string{} scissorsLine := e.CS + " " + Scissors for scanner.Scan() { line := scanner.Text() if line == scissorsLine { break } unquotedLines = append(unquotedLines, line) } if err = scanner.Err(); err != nil { return } content = strings.Join(unquotedLines, "\n") return } func (e *Editor) openAndEdit() (content []byte, err error) { err = e.writeContent() if err != nil { return } err = e.openEditor(e.Program, e.File) if err != nil { err = fmt.Errorf("error using text editor for %s message", e.Topic) defer e.DeleteFile() return } content, err = e.readContent() return } func (e *Editor) writeContent() (err error) { if !e.isFileExist() { err = ioutil.WriteFile(e.File, []byte(e.Message), 0644) if err != nil { return } } return } func (e *Editor) isFileExist() bool { _, err := os.Stat(e.File) return err == nil || !os.IsNotExist(err) } func (e *Editor) readContent() (content []byte, err error) { return ioutil.ReadFile(e.File) } func openTextEditor(program, file string) error { programArgs, err := shellquote.Split(program) if err != nil { return err } editCmd := cmd.NewWithArray(programArgs) r := regexp.MustCompile(`\b(?:[gm]?vim)(?:\.exe)?$`) if r.MatchString(editCmd.Name) { editCmd.WithArg("--cmd") editCmd.WithArg("set ft=gitcommit tw=0 wrap lbr") } editCmd.WithArg(file) // Reattach stdin to the console before opening the editor setConsole(editCmd) return editCmd.Spawn() } ================================================ FILE: github/editor_test.go ================================================ package github import ( "fmt" "io/ioutil" "os" "testing" "github.com/github/hub/v2/internal/assert" ) func TestEditor_openAndEdit_deleteFileWhenOpeningEditorFails(t *testing.T) { tempFile, _ := ioutil.TempFile("", "editor-test") tempFile.Close() ioutil.WriteFile(tempFile.Name(), []byte("hello"), 0644) editor := Editor{ Program: "memory", File: tempFile.Name(), Topic: "test", openEditor: func(program string, file string) error { assert.Equal(t, "memory", program) assert.Equal(t, tempFile.Name(), file) return fmt.Errorf("error") }, } _, err := os.Stat(tempFile.Name()) assert.Equal(t, nil, err) _, err = editor.openAndEdit() assert.Equal(t, "error using text editor for test message", fmt.Sprintf("%s", err)) // file is removed if there's error _, err = os.Stat(tempFile.Name()) assert.T(t, os.IsNotExist(err)) } func TestEditor_openAndEdit_readFileIfExist(t *testing.T) { tempFile, _ := ioutil.TempFile("", "editor-test") tempFile.Close() ioutil.WriteFile(tempFile.Name(), []byte("hello"), 0644) editor := Editor{ Program: "memory", File: tempFile.Name(), openEditor: func(program string, file string) error { assert.Equal(t, "memory", program) assert.Equal(t, tempFile.Name(), file) return nil }, } content, err := editor.openAndEdit() assert.Equal(t, nil, err) assert.Equal(t, "hello", string(content)) } func TestEditor_openAndEdit_writeFileIfNotExist(t *testing.T) { tempFile, _ := ioutil.TempFile("", "PULLREQ") tempFile.Close() editor := Editor{ Program: "memory", File: tempFile.Name(), openEditor: func(program string, file string) error { assert.Equal(t, "memory", program) assert.Equal(t, tempFile.Name(), file) return ioutil.WriteFile(file, []byte("hello"), 0644) }, } content, err := editor.openAndEdit() assert.Equal(t, nil, err) assert.Equal(t, "hello", string(content)) } ================================================ FILE: github/hosts.go ================================================ package github import ( "fmt" "net/url" "os" "strings" "github.com/github/hub/v2/git" ) var ( GitHubHostEnv = os.Getenv("GITHUB_HOST") cachedHosts []string ) type HostError struct { url *url.URL } func (e *HostError) Error() string { return fmt.Sprintf("Invalid GitHub URL: %s", e.url) } func knownGitHubHostsInclude(host string) bool { for _, hh := range knownGitHubHosts() { if hh == host { return true } } return false } func knownGitHubHosts() []string { if cachedHosts != nil { return cachedHosts } hosts := []string{} defaultHost := DefaultGitHubHost() hosts = append(hosts, defaultHost) hosts = append(hosts, "ssh.github.com") ghHosts, _ := git.ConfigAll("hub.host") for _, ghHost := range ghHosts { ghHost = strings.TrimSpace(ghHost) if ghHost != "" { hosts = append(hosts, ghHost) } } cachedHosts = hosts return hosts } func DefaultGitHubHost() string { defaultHost := GitHubHostEnv if defaultHost == "" { defaultHost = GitHubHost } return defaultHost } ================================================ FILE: github/http.go ================================================ package github import ( "bytes" "context" "crypto/md5" "encoding/json" "errors" "fmt" "io" "io/ioutil" "net" "net/http" "net/url" "os" "path" "path/filepath" "regexp" "sort" "strconv" "strings" "time" "github.com/github/hub/v2/ui" "github.com/github/hub/v2/utils" "golang.org/x/net/http/httpproxy" ) const apiPayloadVersion = "application/vnd.github.v3+json;charset=utf-8" const patchMediaType = "application/vnd.github.v3.patch;charset=utf-8" const textMediaType = "text/plain;charset=utf-8" const checksType = "application/vnd.github.antiope-preview+json;charset=utf-8" const draftsType = "application/vnd.github.shadow-cat-preview+json;charset=utf-8" const cacheVersion = 2 const ( rateLimitRemainingHeader = "X-Ratelimit-Remaining" rateLimitResetHeader = "X-Ratelimit-Reset" ) var inspectHeaders = []string{ "Authorization", "X-GitHub-OTP", "X-GitHub-SSO", "X-Oauth-Scopes", "X-Accepted-Oauth-Scopes", "X-Oauth-Client-Id", "X-GitHub-Enterprise-Version", "Location", "Link", "Accept", } type verboseTransport struct { Transport *http.Transport Verbose bool OverrideURL *url.URL Out io.Writer Colorized bool } func (t *verboseTransport) RoundTrip(req *http.Request) (resp *http.Response, err error) { if t.Verbose { t.dumpRequest(req) } if t.OverrideURL != nil { port := "80" if s := strings.Split(req.URL.Host, ":"); len(s) > 1 { port = s[1] } req = cloneRequest(req) req.Header.Set("X-Original-Scheme", req.URL.Scheme) req.Header.Set("X-Original-Port", port) req.Host = req.URL.Host req.URL.Scheme = t.OverrideURL.Scheme req.URL.Host = t.OverrideURL.Host } resp, err = t.Transport.RoundTrip(req) if err == nil && t.Verbose { t.dumpResponse(resp) } return } func (t *verboseTransport) dumpRequest(req *http.Request) { info := fmt.Sprintf("> %s %s://%s%s", req.Method, req.URL.Scheme, req.URL.Host, req.URL.RequestURI()) t.verbosePrintln(info) t.dumpHeaders(req.Header, ">") if inspectableType(req.Header.Get("content-type")) { body := t.dumpBody(req.Body) if body != nil { // reset body since it's been read req.Body = body } } } func (t *verboseTransport) dumpResponse(resp *http.Response) { info := fmt.Sprintf("< HTTP %d", resp.StatusCode) t.verbosePrintln(info) t.dumpHeaders(resp.Header, "<") if inspectableType(resp.Header.Get("content-type")) { body := t.dumpBody(resp.Body) if body != nil { // reset body since it's been read resp.Body = body } } } func (t *verboseTransport) dumpHeaders(header http.Header, indent string) { for _, listed := range inspectHeaders { for name, vv := range header { if !strings.EqualFold(name, listed) { continue } for _, v := range vv { if v != "" { r := regexp.MustCompile("(?i)^(basic|token) (.+)") if r.MatchString(v) { v = r.ReplaceAllString(v, "$1 [REDACTED]") } info := fmt.Sprintf("%s %s: %s", indent, name, v) t.verbosePrintln(info) } } } } } func (t *verboseTransport) dumpBody(body io.ReadCloser) io.ReadCloser { if body == nil { return nil } defer body.Close() buf := new(bytes.Buffer) _, err := io.Copy(buf, body) utils.Check(err) if buf.Len() > 0 { t.verbosePrintln(buf.String()) } return ioutil.NopCloser(buf) } func (t *verboseTransport) verbosePrintln(msg string) { if t.Colorized { msg = fmt.Sprintf("\033[36m%s\033[0m", msg) } fmt.Fprintln(t.Out, msg) } var jsonTypeRE = regexp.MustCompile(`[/+]json($|;)`) func inspectableType(ct string) bool { return strings.HasPrefix(ct, "text/") || jsonTypeRE.MatchString(ct) } func newHTTPClient(testHost string, verbose bool, unixSocket string) *http.Client { var testURL *url.URL if testHost != "" { testURL, _ = url.Parse(testHost) } var httpTransport *http.Transport if unixSocket != "" { dialFunc := func(network, addr string) (net.Conn, error) { return net.Dial("unix", unixSocket) } dialContext := func(_ context.Context, _, _ string) (net.Conn, error) { return net.Dial("unix", unixSocket) } httpTransport = &http.Transport{ DialContext: dialContext, DialTLS: dialFunc, ResponseHeaderTimeout: 30 * time.Second, ExpectContinueTimeout: 10 * time.Second, TLSHandshakeTimeout: 10 * time.Second, } } else { httpTransport = &http.Transport{ Proxy: proxyFromEnvironment, DialContext: (&net.Dialer{ Timeout: 30 * time.Second, KeepAlive: 30 * time.Second, }).DialContext, TLSHandshakeTimeout: 10 * time.Second, } } tr := &verboseTransport{ Transport: httpTransport, Verbose: verbose, OverrideURL: testURL, Out: ui.Stderr, Colorized: ui.IsTerminal(os.Stderr), } return &http.Client{ Transport: tr, CheckRedirect: checkRedirect, } } func checkRedirect(req *http.Request, via []*http.Request) error { var recommendedCode int switch req.Response.StatusCode { case 301: recommendedCode = 308 case 302: recommendedCode = 307 } origMethod := via[len(via)-1].Method if recommendedCode != 0 && !strings.EqualFold(req.Method, origMethod) { return fmt.Errorf( "refusing to follow HTTP %d redirect for a %s request\n"+ "Have your site admin use HTTP %d for this kind of redirect", req.Response.StatusCode, origMethod, recommendedCode) } // inherited from stdlib defaultCheckRedirect if len(via) >= 10 { return errors.New("stopped after 10 redirects") } return nil } func cloneRequest(req *http.Request) *http.Request { dup := new(http.Request) *dup = *req dup.URL, _ = url.Parse(req.URL.String()) dup.Header = make(http.Header) for k, s := range req.Header { dup.Header[k] = s } return dup } var proxyFunc func(*url.URL) (*url.URL, error) func proxyFromEnvironment(req *http.Request) (*url.URL, error) { if proxyFunc == nil { proxyFunc = httpproxy.FromEnvironment().ProxyFunc() } return proxyFunc(req.URL) } type simpleClient struct { httpClient *http.Client rootURL *url.URL PrepareRequest func(*http.Request) CacheTTL int } func (c *simpleClient) performRequest(method, path string, body io.Reader, configure func(*http.Request)) (*simpleResponse, error) { if path == "graphql" { // FIXME: This dirty workaround cancels out the "v3" portion of the // "/api/v3" prefix used for Enterprise. Find a better place for this. path = "../graphql" } url, err := url.Parse(path) if err == nil { url = c.rootURL.ResolveReference(url) return c.performRequestURL(method, url, body, configure) } return nil, err } func (c *simpleClient) performRequestURL(method string, url *url.URL, body io.Reader, configure func(*http.Request)) (res *simpleResponse, err error) { req, err := http.NewRequest(method, url.String(), body) if err != nil { return } if c.PrepareRequest != nil { c.PrepareRequest(req) } req.Header.Set("User-Agent", UserAgent) req.Header.Set("Accept", apiPayloadVersion) if configure != nil { configure(req) } key := cacheKey(req) if cachedResponse := c.cacheRead(key, req); cachedResponse != nil { res = &simpleResponse{cachedResponse} return } httpResponse, err := c.httpClient.Do(req) if err != nil { return } c.cacheWrite(key, httpResponse) res = &simpleResponse{httpResponse} return } func isGraphQL(req *http.Request) bool { return req.URL.Path == "/graphql" } func canCache(req *http.Request) bool { return strings.EqualFold(req.Method, "GET") || isGraphQL(req) } func (c *simpleClient) cacheRead(key string, req *http.Request) (res *http.Response) { if c.CacheTTL > 0 && canCache(req) { f := cacheFile(key) cacheInfo, err := os.Stat(f) if err != nil { return } if time.Since(cacheInfo.ModTime()).Seconds() > float64(c.CacheTTL) { return } cf, err := os.Open(f) if err != nil { return } defer cf.Close() cb, err := ioutil.ReadAll(cf) if err != nil { return } parts := strings.SplitN(string(cb), "\r\n\r\n", 2) if len(parts) < 2 { return } res = &http.Response{ Body: ioutil.NopCloser(bytes.NewBufferString(parts[1])), Header: http.Header{}, Request: req, } headerLines := strings.Split(parts[0], "\r\n") if len(headerLines) < 1 { return } if proto := strings.SplitN(headerLines[0], " ", 3); len(proto) >= 3 { res.Proto = proto[0] res.Status = fmt.Sprintf("%s %s", proto[1], proto[2]) if code, _ := strconv.Atoi(proto[1]); code > 0 { res.StatusCode = code } } for _, line := range headerLines[1:] { kv := strings.SplitN(line, ":", 2) if len(kv) >= 2 { res.Header.Add(kv[0], strings.TrimLeft(kv[1], " ")) } } } return } func (c *simpleClient) cacheWrite(key string, res *http.Response) { if c.CacheTTL > 0 && canCache(res.Request) && res.StatusCode < 500 && res.StatusCode != 403 { bodyCopy := &bytes.Buffer{} bodyReplacement := readCloserCallback{ Reader: io.TeeReader(res.Body, bodyCopy), Closer: res.Body, Callback: func() { f := cacheFile(key) err := os.MkdirAll(filepath.Dir(f), 0771) if err != nil { return } cf, err := os.OpenFile(f, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) if err != nil { return } defer cf.Close() fmt.Fprintf(cf, "%s %s\r\n", res.Proto, res.Status) res.Header.Write(cf) fmt.Fprintf(cf, "\r\n") io.Copy(cf, bodyCopy) }, } res.Body = &bodyReplacement } } type readCloserCallback struct { Callback func() Closer io.Closer io.Reader } func (rc *readCloserCallback) Close() error { err := rc.Closer.Close() if err == nil { rc.Callback() } return err } func cacheKey(req *http.Request) string { path := strings.Replace(req.URL.EscapedPath(), "/", "-", -1) if len(path) > 1 { path = strings.TrimPrefix(path, "-") } host := req.Host if host == "" { host = req.URL.Host } hash := md5.New() fmt.Fprintf(hash, "%d:", cacheVersion) io.WriteString(hash, req.Header.Get("Accept")) io.WriteString(hash, req.Header.Get("Authorization")) queryParts := strings.Split(req.URL.RawQuery, "&") sort.Strings(queryParts) for _, q := range queryParts { fmt.Fprintf(hash, "%s&", q) } if isGraphQL(req) && req.Body != nil { if b, err := ioutil.ReadAll(req.Body); err == nil { req.Body = ioutil.NopCloser(bytes.NewBuffer(b)) hash.Write(b) } } return fmt.Sprintf("%s/%s_%x", host, path, hash.Sum(nil)) } func cacheFile(key string) string { return path.Join(os.TempDir(), "hub", "api", key) } func (c *simpleClient) jsonRequest(method, path string, body interface{}, configure func(*http.Request)) (*simpleResponse, error) { json, err := json.Marshal(body) if err != nil { return nil, err } buf := bytes.NewBuffer(json) return c.performRequest(method, path, buf, func(req *http.Request) { req.Header.Set("Content-Type", "application/json; charset=utf-8") if configure != nil { configure(req) } }) } func (c *simpleClient) Get(path string) (*simpleResponse, error) { return c.performRequest("GET", path, nil, nil) } func (c *simpleClient) GetFile(path string, mimeType string) (*simpleResponse, error) { return c.performRequest("GET", path, nil, func(req *http.Request) { req.Header.Set("Accept", mimeType) }) } func (c *simpleClient) Delete(path string) (*simpleResponse, error) { return c.performRequest("DELETE", path, nil, nil) } func (c *simpleClient) PostJSON(path string, payload interface{}) (*simpleResponse, error) { return c.jsonRequest("POST", path, payload, nil) } func (c *simpleClient) PostJSONPreview(path string, payload interface{}, mimeType string) (*simpleResponse, error) { return c.jsonRequest("POST", path, payload, func(req *http.Request) { req.Header.Set("Accept", mimeType) }) } func (c *simpleClient) PutJSON(path string, payload interface{}) (*simpleResponse, error) { return c.jsonRequest("PUT", path, payload, nil) } func (c *simpleClient) PatchJSON(path string, payload interface{}) (*simpleResponse, error) { return c.jsonRequest("PATCH", path, payload, nil) } func (c *simpleClient) PostFile(path string, contents io.Reader, fileSize int64) (*simpleResponse, error) { return c.performRequest("POST", path, contents, func(req *http.Request) { if fileSize > 0 { req.ContentLength = fileSize } req.Header.Set("Content-Type", "application/octet-stream") }) } type simpleResponse struct { *http.Response } type errorInfo struct { Message string `json:"message"` Errors []fieldError `json:"errors"` Response *http.Response } type errorInfoSimple struct { Message string `json:"message"` Errors []string `json:"errors"` } type fieldError struct { Resource string `json:"resource"` Message string `json:"message"` Code string `json:"code"` Field string `json:"field"` } func (e *errorInfo) Error() string { return e.Message } func (res *simpleResponse) Unmarshal(dest interface{}) (err error) { defer res.Body.Close() body, err := ioutil.ReadAll(res.Body) if err != nil { return } return json.Unmarshal(body, dest) } func (res *simpleResponse) ErrorInfo() (msg *errorInfo, err error) { defer res.Body.Close() body, err := ioutil.ReadAll(res.Body) if err != nil { return } msg = &errorInfo{} err = json.Unmarshal(body, msg) if err != nil { msgSimple := &errorInfoSimple{} if err = json.Unmarshal(body, msgSimple); err == nil { msg.Message = msgSimple.Message for _, errMsg := range msgSimple.Errors { msg.Errors = append(msg.Errors, fieldError{ Code: "custom", Message: errMsg, }) } } } if err == nil { msg.Response = res.Response } return } func (res *simpleResponse) Link(name string) string { linkVal := res.Header.Get("Link") re := regexp.MustCompile(`<([^>]+)>; rel="([^"]+)"`) for _, match := range re.FindAllStringSubmatch(linkVal, -1) { if match[2] == name { return match[1] } } return "" } func (res *simpleResponse) RateLimitRemaining() int { if v := res.Header.Get(rateLimitRemainingHeader); len(v) > 0 { if num, err := strconv.Atoi(v); err == nil { return num } } return -1 } func (res *simpleResponse) RateLimitReset() int { if v := res.Header.Get(rateLimitResetHeader); len(v) > 0 { if ts, err := strconv.Atoi(v); err == nil { return ts } } return -1 } ================================================ FILE: github/http_test.go ================================================ package github import ( "bytes" "fmt" "io/ioutil" "log" "net" "net/http" "net/http/httptest" "net/url" "os" "testing" "github.com/github/hub/v2/internal/assert" ) func setupTestServer(unixSocket string) *testServer { m := http.NewServeMux() s := httptest.NewServer(m) u, _ := url.Parse(s.URL) if unixSocket != "" { os.Remove(unixSocket) unixListener, err := net.Listen("unix", unixSocket) if err != nil { log.Fatal("Unable to listen on unix-socket: ", err) } go http.Serve(unixListener, m) } return &testServer{ Server: s, ServeMux: m, URL: u, } } type testServer struct { *http.ServeMux Server *httptest.Server URL *url.URL } func (s *testServer) Close() { s.Server.Close() } func TestNewHttpClient_OverrideURL(t *testing.T) { s := setupTestServer("") defer s.Close() s.HandleFunc("/override", func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "https", r.Header.Get("X-Original-Scheme")) assert.Equal(t, "example.com", r.Host) }) c := newHTTPClient(s.URL.String(), false, "") c.Get("https://example.com/override") s.HandleFunc("/not-override", func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "", r.Header.Get("X-Original-Scheme")) assert.Equal(t, s.URL.Host, r.Host) }) c = newHTTPClient("", false, "") c.Get(fmt.Sprintf("%s/not-override", s.URL.String())) } func TestNewHttpClient_UnixSocket(t *testing.T) { sock := "/tmp/hub-go.sock" s := setupTestServer(sock) defer s.Close() s.HandleFunc("/unix-socket", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("unix-socket-works")) }) c := newHTTPClient("", false, sock) resp, err := c.Get(fmt.Sprintf("%s/unix-socket", s.URL.String())) assert.Equal(t, nil, err) result, _ := ioutil.ReadAll(resp.Body) assert.Equal(t, "unix-socket-works", string(result)) } func TestVerboseTransport_VerbosePrintln(t *testing.T) { var b bytes.Buffer tr := &verboseTransport{ Out: &b, Colorized: true, } tr.verbosePrintln("foo") assert.Equal(t, "\033[36mfoo\033[0m\n", b.String()) } ================================================ FILE: github/localrepo.go ================================================ package github import ( "fmt" "net/url" "strings" "github.com/github/hub/v2/git" ) func LocalRepo() (repo *GitHubRepo, err error) { repo = &GitHubRepo{} _, err = git.Dir() if err != nil { err = fmt.Errorf("fatal: Not a git repository") return } return } type GitHubRepo struct { remotes []Remote } func (r *GitHubRepo) loadRemotes() error { if r.remotes != nil { return nil } remotes, err := Remotes() if err != nil { return err } r.remotes = remotes return nil } func (r *GitHubRepo) RemoteByName(name string) (*Remote, error) { if err := r.loadRemotes(); err != nil { return nil, err } for _, remote := range r.remotes { if remote.Name == name { return &remote, nil } } return nil, fmt.Errorf("No git remote with name %s", name) } func (r *GitHubRepo) remotesForPublish(owner string) (remotes []Remote) { r.loadRemotes() remotesMap := make(map[string]Remote) if owner != "" { for _, remote := range r.remotes { p, e := remote.Project() if e == nil && strings.EqualFold(p.Owner, owner) { remotesMap[remote.Name] = remote } } } names := OriginNamesInLookupOrder for _, name := range names { if _, ok := remotesMap[name]; ok { continue } remote, err := r.RemoteByName(name) if err == nil { remotesMap[remote.Name] = *remote } } for i := len(names) - 1; i >= 0; i-- { name := names[i] if remote, ok := remotesMap[name]; ok { remotes = append(remotes, remote) delete(remotesMap, name) } } // anything other than names has higher priority for _, remote := range remotesMap { remotes = append([]Remote{remote}, remotes...) } return } func (r *GitHubRepo) CurrentBranch() (branch *Branch, err error) { head, err := git.Head() if err != nil { err = fmt.Errorf("Aborted: not currently on any branch.") return } branch = &Branch{r, head} return } func (r *GitHubRepo) MasterBranch() *Branch { if remote, err := r.MainRemote(); err == nil { return r.DefaultBranch(remote) } return r.DefaultBranch(nil) } func (r *GitHubRepo) DefaultBranch(remote *Remote) *Branch { b := Branch{ Repo: r, Name: "refs/heads/master", } if remote != nil { if name, err := git.SymbolicRef(fmt.Sprintf("refs/remotes/%s/HEAD", remote.Name)); err == nil { b.Name = name } } return &b } func (r *GitHubRepo) RemoteBranchAndProject(owner string, preferUpstream bool) (branch *Branch, project *Project, err error) { if err = r.loadRemotes(); err != nil { return } for _, remote := range r.remotes { if p, err := remote.Project(); err == nil { project = p break } } branch, err = r.CurrentBranch() if err != nil { return } if project == nil { return } pushDefault, _ := git.Config("push.default") if pushDefault == "upstream" || pushDefault == "tracking" { upstream, e := branch.Upstream() if e == nil && upstream.IsRemote() { remote, e := r.RemoteByName(upstream.RemoteName()) if e == nil { p, e := remote.Project() if e == nil { branch = upstream project = p return } } } } shortName := branch.ShortName() remotes := r.remotesForPublish(owner) if preferUpstream { // reverse the remote lookup order; see OriginNamesInLookupOrder remotesInOrder := []Remote{} for i := len(remotes) - 1; i >= 0; i-- { remotesInOrder = append(remotesInOrder, remotes[i]) } remotes = remotesInOrder } for _, remote := range remotes { p, e := remote.Project() if e != nil { continue } // NOTE: this is similar RemoteForBranch if git.HasFile("refs", "remotes", remote.Name, shortName) { name := fmt.Sprintf("refs/remotes/%s/%s", remote.Name, shortName) branch = &Branch{r, name} project = p return } } branch = nil return } func (r *GitHubRepo) RemoteForBranch(branch *Branch, owner string) *Remote { branchName := branch.ShortName() for _, remote := range r.remotesForPublish(owner) { if git.HasFile("refs", "remotes", remote.Name, branchName) { return &remote } } return nil } func (r *GitHubRepo) RemoteForRepo(repo *Repository) (*Remote, error) { if err := r.loadRemotes(); err != nil { return nil, err } repoURL, err := url.Parse(repo.HTMLURL) if err != nil { return nil, err } project := NewProject(repo.Owner.Login, repo.Name, repoURL.Host) for _, remote := range r.remotes { if rp, err := remote.Project(); err == nil { if rp.SameAs(project) { return &remote, nil } } } return nil, fmt.Errorf("could not find a git remote for '%s/%s'", repo.Owner.Login, repo.Name) } func (r *GitHubRepo) RemoteForProject(project *Project) (*Remote, error) { if err := r.loadRemotes(); err != nil { return nil, err } for _, remote := range r.remotes { remoteProject, err := remote.Project() if err == nil && remoteProject.SameAs(project) { return &remote, nil } } return nil, fmt.Errorf("could not find a git remote for '%s'", project) } func (r *GitHubRepo) MainRemote() (*Remote, error) { r.loadRemotes() if len(r.remotes) > 0 { return &r.remotes[0], nil } return nil, fmt.Errorf("no git remotes found") } func (r *GitHubRepo) MainProject() (*Project, error) { r.loadRemotes() for _, remote := range r.remotes { if project, err := remote.Project(); err == nil { return project, nil } } return nil, fmt.Errorf("Aborted: could not find any git remote pointing to a GitHub repository") } func (r *GitHubRepo) CurrentProject() (project *Project, err error) { project, err = r.UpstreamProject() if err != nil { project, err = r.MainProject() } return } func (r *GitHubRepo) UpstreamProject() (project *Project, err error) { currentBranch, err := r.CurrentBranch() if err != nil { return } upstream, err := currentBranch.Upstream() if err != nil { return } remote, err := r.RemoteByName(upstream.RemoteName()) if err != nil { return } project, err = remote.Project() return } ================================================ FILE: github/localrepo_test.go ================================================ package github import ( "net/url" "testing" "github.com/github/hub/v2/internal/assert" ) func TestGitHubRepo_remotesForPublish(t *testing.T) { url, _ := url.Parse("ssh://git@github.com/Owner/repo.git") remotes := []Remote{ { Name: "Owner", URL: url, }, } repo := GitHubRepo{remotes} remotesForPublish := repo.remotesForPublish("owner") assert.Equal(t, 1, len(remotesForPublish)) assert.Equal(t, "Owner", remotesForPublish[0].Name) assert.Equal(t, url.String(), remotesForPublish[0].URL.String()) } ================================================ FILE: github/message_builder.go ================================================ package github import ( "regexp" "strings" ) type MessageBuilder struct { Title string Filename string Message string Edit bool commentedSections []string editor *Editor } func (b *MessageBuilder) AddCommentedSection(section string) { b.commentedSections = append(b.commentedSections, section) } func (b *MessageBuilder) Extract() (title, body string, err error) { content := b.Message if b.Edit { b.editor, err = NewEditor(b.Filename, b.Title, content) if err != nil { return } for _, section := range b.commentedSections { b.editor.AddCommentedSection(section) } content, err = b.editor.EditContent() if err != nil { return } } else { nl := regexp.MustCompile(`\r?\n`) content = nl.ReplaceAllString(content, "\n") } title, body = SplitTitleBody(content) if title == "" { defer b.Cleanup() } return } func (b *MessageBuilder) Cleanup() { if b.editor != nil { b.editor.DeleteFile() } } func SplitTitleBody(content string) (title string, body string) { parts := strings.SplitN(content, "\n\n", 2) if len(parts) >= 1 { title = strings.TrimSpace(strings.Replace(parts[0], "\n", " ", -1)) } if len(parts) >= 2 { body = strings.TrimSpace(parts[1]) } return } ================================================ FILE: github/message_builder_test.go ================================================ package github import ( "testing" "github.com/github/hub/v2/internal/assert" ) func TestMessageBuilder_multiline_title(t *testing.T) { builder := &MessageBuilder{ Message: `hello multiline text the rest is description`, } title, body, err := builder.Extract() assert.Equal(t, nil, err) assert.Equal(t, "hello multiline text", title) assert.Equal(t, "the rest is\ndescription", body) } ================================================ FILE: github/project.go ================================================ package github import ( "fmt" "net/url" "os" "path/filepath" "strings" "github.com/github/hub/v2/git" "github.com/github/hub/v2/utils" ) type Project struct { Name string Owner string Host string Protocol string } func (p Project) String() string { return fmt.Sprintf("%s/%s", p.Owner, p.Name) } func (p *Project) SameAs(other *Project) bool { return strings.EqualFold(p.Owner, other.Owner) && strings.EqualFold(p.Name, other.Name) && strings.EqualFold(p.Host, other.Host) } func (p *Project) WebURL(name, owner, path string) string { if owner == "" { owner = p.Owner } if name == "" { name = p.Name } ownerWithName := fmt.Sprintf("%s/%s", owner, name) if strings.Contains(ownerWithName, ".wiki") { ownerWithName = strings.TrimSuffix(ownerWithName, ".wiki") if path != "wiki" { if strings.HasPrefix(path, "commits") { path = "_history" } else if path != "" { path = fmt.Sprintf("_%s", path) } if path != "" { path = utils.ConcatPaths("wiki", path) } else { path = "wiki" } } } url := fmt.Sprintf("%s://%s", p.Protocol, utils.ConcatPaths(p.Host, ownerWithName)) if path != "" { url = utils.ConcatPaths(url, path) } return url } func (p *Project) GitURL(name, owner string, allowPush bool) string { if name == "" { name = p.Name } if owner == "" { owner = p.Owner } host := rawHost(p.Host) switch preferredProtocol() { case "git": if allowPush { return fmt.Sprintf("git@%s:%s/%s.git", host, owner, name) } return fmt.Sprintf("git://%s/%s/%s.git", host, owner, name) case "ssh": return fmt.Sprintf("git@%s:%s/%s.git", host, owner, name) default: return fmt.Sprintf("https://%s/%s/%s.git", host, owner, name) } } // Remove the scheme from host when the host url is absolute. func rawHost(host string) string { u, err := url.Parse(host) utils.Check(err) if u.IsAbs() { return u.Host } return u.Path } func preferredProtocol() string { userProtocol := os.Getenv("HUB_PROTOCOL") if userProtocol == "" { userProtocol, _ = git.Config("hub.protocol") } return userProtocol } func NewProjectFromRepo(repo *Repository) (p *Project, err error) { url, err := url.Parse(repo.HTMLURL) if err != nil { return } p, err = NewProjectFromURL(url) return } func NewProjectFromURL(url *url.URL) (p *Project, err error) { if !knownGitHubHostsInclude(url.Host) { err = &HostError{url} return } parts := strings.SplitN(url.Path, "/", 4) if len(parts) <= 2 { err = fmt.Errorf("Invalid GitHub URL: %s", url) return } name := strings.TrimSuffix(parts[2], ".git") p = newProject(parts[1], name, url.Host, url.Scheme) return } func NewProject(owner, name, host string) *Project { return newProject(owner, name, host, "") } func newProject(owner, name, host, protocol string) *Project { if strings.Contains(owner, "/") { result := strings.SplitN(owner, "/", 2) owner = result[0] if name == "" { name = result[1] } } else if strings.Contains(name, "/") { result := strings.SplitN(name, "/", 2) if owner == "" { owner = result[0] } name = result[1] } if host == "" { host = DefaultGitHubHost() } if host == "ssh.github.com" { host = GitHubHost } if protocol != "http" && protocol != "https" { protocol = "" } if protocol == "" { h := CurrentConfig().Find(host) if h != nil { protocol = h.Protocol } } if protocol == "" { protocol = "https" } if owner == "" { h := CurrentConfig().Find(host) if h != nil { owner = h.User } } return &Project{ Name: name, Owner: owner, Host: host, Protocol: protocol, } } func SanitizeProjectName(name string) string { name = filepath.Base(name) return strings.Replace(name, " ", "-", -1) } ================================================ FILE: github/project_test.go ================================================ package github import ( "net/url" "os" "testing" "github.com/github/hub/v2/fixtures" "github.com/github/hub/v2/internal/assert" ) func TestSameAs(t *testing.T) { tests := []struct { name string project1 *Project project2 *Project want bool }{ { name: "same project", project1: &Project{ Owner: "fOo", Name: "baR", Host: "gItHUb.com", }, project2: &Project{ Owner: "FoO", Name: "BAr", Host: "GithUB.com", }, want: true, }, { name: "different project", project1: &Project{ Owner: "foo", Name: "bar", Host: "github.com", }, project2: &Project{ Owner: "foo", Name: "baz", Host: "github.com", }, want: false, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { got := test.project1.SameAs(test.project2) want := test.want assert.Equal(t, want, got) }) } } func TestProject_WebURL(t *testing.T) { project := Project{ Name: "foo", Owner: "bar", Host: "github.com", Protocol: "https", } url := project.WebURL("", "", "baz") assert.Equal(t, "https://github.com/bar/foo/baz", url) url = project.WebURL("1", "2", "") assert.Equal(t, "https://github.com/2/1", url) url = project.WebURL("hub.wiki", "defunkt", "") assert.Equal(t, "https://github.com/defunkt/hub/wiki", url) url = project.WebURL("hub.wiki", "defunkt", "commits") assert.Equal(t, "https://github.com/defunkt/hub/wiki/_history", url) url = project.WebURL("hub.wiki", "defunkt", "pages") assert.Equal(t, "https://github.com/defunkt/hub/wiki/_pages", url) } func TestProject_GitURL(t *testing.T) { os.Setenv("HUB_PROTOCOL", "https") defer os.Setenv("HUB_PROTOCOL", "") project := Project{ Name: "foo", Owner: "bar", Host: "github.com", } url := project.GitURL("gh", "jingweno", false) assert.Equal(t, "https://github.com/jingweno/gh.git", url) os.Setenv("HUB_PROTOCOL", "git") url = project.GitURL("gh", "jingweno", false) assert.Equal(t, "git://github.com/jingweno/gh.git", url) os.Setenv("HUB_PROTOCOL", "ssh") url = project.GitURL("gh", "jingweno", true) assert.Equal(t, "git@github.com:jingweno/gh.git", url) url = project.GitURL("gh", "jingweno", true) assert.Equal(t, "git@github.com:jingweno/gh.git", url) } func TestProject_GitURLEnterprise(t *testing.T) { project := Project{ Name: "foo", Owner: "bar", Host: "https://github.corporate.com", } defer os.Setenv("HUB_PROTOCOL", "") os.Setenv("HUB_PROTOCOL", "https") url := project.GitURL("gh", "jingweno", false) assert.Equal(t, "https://github.corporate.com/jingweno/gh.git", url) os.Setenv("HUB_PROTOCOL", "ssh") url = project.GitURL("gh", "jingweno", false) assert.Equal(t, "git@github.corporate.com:jingweno/gh.git", url) os.Setenv("HUB_PROTOCOL", "git") url = project.GitURL("gh", "jingweno", false) assert.Equal(t, "git://github.corporate.com/jingweno/gh.git", url) url = project.GitURL("gh", "jingweno", true) assert.Equal(t, "git@github.corporate.com:jingweno/gh.git", url) } func TestProject_NewProjectFromURL(t *testing.T) { testConfigs := fixtures.SetupTestConfigs() defer testConfigs.TearDown() u, _ := url.Parse("ssh://git@github.com/octokit/go-octokit.git") p, err := NewProjectFromURL(u) assert.Equal(t, nil, err) assert.Equal(t, "go-octokit", p.Name) assert.Equal(t, "octokit", p.Owner) assert.Equal(t, "github.com", p.Host) assert.Equal(t, "http", p.Protocol) u, _ = url.Parse("ssh://ssh.github.com/octokit/go-octokit.git") p, err = NewProjectFromURL(u) assert.Equal(t, nil, err) assert.Equal(t, "go-octokit", p.Name) assert.Equal(t, "octokit", p.Owner) assert.Equal(t, "github.com", p.Host) assert.Equal(t, "http", p.Protocol) u, _ = url.Parse("git://github.com/octokit/go-octokit.git") p, err = NewProjectFromURL(u) assert.Equal(t, nil, err) assert.Equal(t, "go-octokit", p.Name) assert.Equal(t, "octokit", p.Owner) assert.Equal(t, "github.com", p.Host) assert.Equal(t, "http", p.Protocol) u, _ = url.Parse("https://github.com/octokit/go-octokit") p, err = NewProjectFromURL(u) assert.Equal(t, nil, err) assert.Equal(t, "go-octokit", p.Name) assert.Equal(t, "octokit", p.Owner) assert.Equal(t, "github.com", p.Host) assert.Equal(t, "https", p.Protocol) u, _ = url.Parse("origin/master") _, err = NewProjectFromURL(u) assert.NotEqual(t, nil, err) } ================================================ FILE: github/remote.go ================================================ package github import ( "fmt" "net/url" "regexp" "strings" "github.com/github/hub/v2/git" ) var ( OriginNamesInLookupOrder = []string{"upstream", "github", "origin"} ) type Remote struct { Name string URL *url.URL PushURL *url.URL } func (remote *Remote) String() string { return remote.Name } func (remote *Remote) Project() (*Project, error) { p, err := NewProjectFromURL(remote.URL) if _, ok := err.(*HostError); ok { return NewProjectFromURL(remote.PushURL) } return p, err } func Remotes() (remotes []Remote, err error) { re := regexp.MustCompile(`(.+)\s+(.+)\s+\((push|fetch)\)`) rs, err := git.Remotes() if err != nil { err = fmt.Errorf("Can't load git remote") return } // build the remotes map remotesMap := make(map[string]map[string]string) for _, r := range rs { if re.MatchString(r) { match := re.FindStringSubmatch(r) name := strings.TrimSpace(match[1]) url := strings.TrimSpace(match[2]) urlType := strings.TrimSpace(match[3]) utm, ok := remotesMap[name] if !ok { utm = make(map[string]string) remotesMap[name] = utm } utm[urlType] = url } } // construct remotes in priority order names := OriginNamesInLookupOrder for _, name := range names { if u, ok := remotesMap[name]; ok { r, err := newRemote(name, u) if err == nil { remotes = append(remotes, r) delete(remotesMap, name) } } } // the rest of the remotes for n, u := range remotesMap { r, err := newRemote(n, u) if err == nil { remotes = append(remotes, r) } } return } func newRemote(name string, urlMap map[string]string) (Remote, error) { r := Remote{} fetchURL, ferr := git.ParseURL(urlMap["fetch"]) pushURL, perr := git.ParseURL(urlMap["push"]) if ferr != nil && perr != nil { return r, fmt.Errorf("No valid remote URLs") } r.Name = name if ferr == nil { r.URL = fetchURL } if perr == nil { r.PushURL = pushURL } return r, nil } ================================================ FILE: github/remote_test.go ================================================ package github import ( "testing" "github.com/github/hub/v2/fixtures" "github.com/github/hub/v2/internal/assert" ) func TestGithubRemote_NoPush(t *testing.T) { repo := fixtures.SetupTestRepo() defer repo.TearDown() remoteName := "upstream" repo.AddRemote(remoteName, "user@example.com:test/project.git", "no_push") remotes, err := Remotes() assert.Equal(t, nil, err) assert.Equal(t, len(remotes), 2) assert.Equal(t, remotes[0].Name, remoteName) assert.Equal(t, remotes[0].URL.Scheme, "ssh") assert.Equal(t, remotes[0].URL.Host, "example.com") assert.Equal(t, remotes[0].URL.Path, "/test/project.git") assert.Equal(t, remotes[1].Name, "origin") assert.Equal(t, remotes[1].URL.Path, repo.Remote) } func TestGithubRemote_GitPlusSsh(t *testing.T) { repo := fixtures.SetupTestRepo() defer repo.TearDown() remoteName := "upstream" repo.AddRemote(remoteName, "git+ssh://git@github.com/frozencemetery/python-gssapi", "") remotes, err := Remotes() assert.Equal(t, nil, err) assert.Equal(t, len(remotes), 2) assert.Equal(t, remotes[0].Name, remoteName) assert.Equal(t, remotes[0].URL.Scheme, "ssh") assert.Equal(t, remotes[0].URL.Host, "github.com") assert.Equal(t, remotes[0].URL.Path, "/frozencemetery/python-gssapi") assert.Equal(t, remotes[1].Name, "origin") assert.Equal(t, remotes[1].URL.Path, repo.Remote) } func TestGithubRemote_SshPort(t *testing.T) { repo := fixtures.SetupTestRepo() defer repo.TearDown() remoteName := "upstream" repo.AddRemote(remoteName, "ssh://git@github.com:22/hakatashi/dotfiles.git", "") remotes, err := Remotes() assert.Equal(t, nil, err) assert.Equal(t, len(remotes), 2) assert.Equal(t, remotes[0].Name, remoteName) assert.Equal(t, remotes[0].URL.Scheme, "ssh") assert.Equal(t, remotes[0].URL.Host, "github.com") assert.Equal(t, remotes[0].URL.Path, "/hakatashi/dotfiles.git") assert.Equal(t, remotes[1].Name, "origin") assert.Equal(t, remotes[1].URL.Path, repo.Remote) } func TestGithubRemote_ColonSlash(t *testing.T) { repo := fixtures.SetupTestRepo() defer repo.TearDown() remoteName := "upstream" repo.AddRemote(remoteName, "git@github.com:/fatso83/my-project.git", "") remotes, err := Remotes() assert.Equal(t, nil, err) assert.Equal(t, len(remotes), 2) assert.Equal(t, remotes[0].Name, remoteName) assert.Equal(t, remotes[0].URL.Scheme, "ssh") assert.Equal(t, remotes[0].URL.Host, "github.com") assert.Equal(t, remotes[0].URL.Path, "/fatso83/my-project.git") assert.Equal(t, remotes[1].Name, "origin") assert.Equal(t, remotes[1].URL.Path, repo.Remote) } ================================================ FILE: github/reset_console.go ================================================ //go:build !windows // +build !windows package github import ( "os" "github.com/github/hub/v2/cmd" ) func setConsole(cmd *cmd.Cmd) { stdin, err := os.OpenFile("/dev/tty", os.O_RDONLY, 0660) if err == nil { cmd.Stdin = stdin } } ================================================ FILE: github/reset_console_windows.go ================================================ //go:build windows // +build windows package github import "github.com/github/hub/v2/cmd" // This does nothing on windows func setConsole(cmd *cmd.Cmd) { } ================================================ FILE: github/template.go ================================================ package github import ( "io/ioutil" "os" "path/filepath" "sort" "strings" ) const ( PullRequestTemplate = "pull_request_template" IssueTemplate = "issue_template" githubTemplateDir = ".github" docsDir = "docs" ) func ReadTemplate(kind, workdir string) (body string, err error) { templateDir := filepath.Join(workdir, githubTemplateDir) path, err := getFilePath(templateDir, kind) if err != nil || path == "" { docsDir := filepath.Join(workdir, docsDir) path, err = getFilePath(docsDir, kind) } if err != nil || path == "" { path, err = getFilePath(workdir, kind) } if path != "" { body, err = readContentsFromFile(path) } return } type sortedFiles []os.FileInfo func (s sortedFiles) Len() int { return len(s) } func (s sortedFiles) Swap(i, j int) { s[i], s[j] = s[j], s[i] } func (s sortedFiles) Less(i, j int) bool { return strings.Compare(strings.ToLower(s[i].Name()), strings.ToLower(s[j].Name())) > 0 } func getFilePath(dir, pattern string) (found string, err error) { files, err := ioutil.ReadDir(dir) if err != nil { return } sort.Sort(sortedFiles(files)) for _, file := range files { fileName := file.Name() path := strings.TrimSuffix(fileName, ".md") path = strings.TrimSuffix(path, ".txt") if strings.EqualFold(pattern, path) { found = filepath.Join(dir, fileName) return } } return } func readContentsFromFile(filename string) (contents string, err error) { content, err := ioutil.ReadFile(filename) if err != nil { if strings.HasSuffix(err.Error(), " is a directory") { err = nil } return } contents = strings.Replace(string(content), "\r\n", "\n", -1) contents = strings.TrimSuffix(contents, "\n") return } ================================================ FILE: github/template_test.go ================================================ package github import ( "os" "path/filepath" "testing" "github.com/github/hub/v2/fixtures" "github.com/github/hub/v2/internal/assert" ) var prContent = `Description ----------- [Enter your pull request description here]` var issueContent = `Description ----------- [Enter your issue description here]` func TestGithubTemplate_withoutTemplate(t *testing.T) { repo := fixtures.SetupTestRepo() defer repo.TearDown() pwd, _ := os.Getwd() tpl, err := ReadTemplate(PullRequestTemplate, pwd) assert.Equal(t, nil, err) assert.Equal(t, "", tpl) tpl, err = ReadTemplate(IssueTemplate, pwd) assert.Equal(t, nil, err) assert.Equal(t, "", tpl) } func TestGithubTemplate_withInvalidTemplate(t *testing.T) { repo := fixtures.SetupTestRepo() defer repo.TearDown() addGithubTemplates(repo, map[string]string{"dir": "invalidPath"}) pwd, _ := os.Getwd() tpl, err := ReadTemplate(PullRequestTemplate, pwd) assert.Equal(t, nil, err) assert.Equal(t, "", tpl) tpl, err = ReadTemplate(IssueTemplate, pwd) assert.Equal(t, nil, err) assert.Equal(t, "", tpl) } func TestGithubTemplate_WithMarkdown(t *testing.T) { repo := fixtures.SetupTestRepo() defer repo.TearDown() addGithubTemplates(repo, map[string]string{ "prTemplate": PullRequestTemplate + ".md", "issueTemplate": IssueTemplate + ".md", }) pwd, _ := os.Getwd() tpl, err := ReadTemplate(PullRequestTemplate, pwd) assert.Equal(t, nil, err) assert.Equal(t, prContent, tpl) tpl, err = ReadTemplate(IssueTemplate, pwd) assert.Equal(t, nil, err) assert.Equal(t, issueContent, tpl) } func TestGithubTemplate_WithTemplateInHome(t *testing.T) { repo := fixtures.SetupTestRepo() defer repo.TearDown() addGithubTemplates(repo, map[string]string{}) pwd, _ := os.Getwd() tpl, err := ReadTemplate(PullRequestTemplate, pwd) assert.Equal(t, nil, err) assert.Equal(t, prContent, tpl) tpl, err = ReadTemplate(IssueTemplate, pwd) assert.Equal(t, nil, err) assert.Equal(t, issueContent, tpl) } func TestGithubTemplate_WithTemplateInGithubDir(t *testing.T) { repo := fixtures.SetupTestRepo() defer repo.TearDown() addGithubTemplates(repo, map[string]string{"dir": githubTemplateDir}) pwd, _ := os.Getwd() tpl, err := ReadTemplate(PullRequestTemplate, pwd) assert.Equal(t, nil, err) assert.Equal(t, prContent, tpl) tpl, err = ReadTemplate(IssueTemplate, pwd) assert.Equal(t, nil, err) assert.Equal(t, issueContent, tpl) } func TestGithubTemplate_WithTemplateInGithubDirAndMarkdown(t *testing.T) { repo := fixtures.SetupTestRepo() defer repo.TearDown() addGithubTemplates(repo, map[string]string{ "prTemplate": PullRequestTemplate + ".md", "issueTemplate": IssueTemplate + ".md", "dir": githubTemplateDir, }) pwd, _ := os.Getwd() tpl, err := ReadTemplate(PullRequestTemplate, pwd) assert.Equal(t, nil, err) assert.Equal(t, prContent, tpl) tpl, err = ReadTemplate(IssueTemplate, pwd) assert.Equal(t, nil, err) assert.Equal(t, issueContent, tpl) } func TestGithubTemplate_WithTemplateInDocsDir(t *testing.T) { repo := fixtures.SetupTestRepo() defer repo.TearDown() addGithubTemplates(repo, map[string]string{"dir": docsDir}) pwd, _ := os.Getwd() tpl, err := ReadTemplate(PullRequestTemplate, pwd) assert.Equal(t, nil, err) assert.Equal(t, prContent, tpl) tpl, err = ReadTemplate(IssueTemplate, pwd) assert.Equal(t, nil, err) assert.Equal(t, issueContent, tpl) } func addGithubTemplates(r *fixtures.TestRepo, config map[string]string) { repoDir := "test.git" if dir := config["dir"]; dir != "" { repoDir = filepath.Join(repoDir, dir) } prTemplatePath := filepath.Join(repoDir, PullRequestTemplate) if prTmplPath := config["prTemplate"]; prTmplPath != "" { prTemplatePath = filepath.Join(repoDir, prTmplPath) } issueTemplatePath := filepath.Join(repoDir, IssueTemplate) if issueTmplPath := config["issueTemplate"]; issueTmplPath != "" { issueTemplatePath = filepath.Join(repoDir, issueTmplPath) } r.AddFile(prTemplatePath, prContent) r.AddFile(issueTemplatePath, issueContent) } ================================================ FILE: github/url.go ================================================ package github import ( "net/url" "strings" ) type URL struct { url.URL *Project } func (url URL) ProjectPath() (projectPath string) { split := strings.SplitN(url.Path, "/", 4) if len(split) > 3 { projectPath = split[3] } return } func ParseURL(rawurl string) (*URL, error) { url, err := url.Parse(rawurl) if err != nil { return nil, err } project, err := NewProjectFromURL(url) if err != nil { return nil, err } return &URL{Project: project, URL: *url}, nil } ================================================ FILE: github/url_test.go ================================================ package github import ( "testing" "github.com/github/hub/v2/fixtures" "github.com/github/hub/v2/internal/assert" ) func TestParseURL(t *testing.T) { testConfigs := fixtures.SetupTestConfigs() defer testConfigs.TearDown() url, err := ParseURL("https://github.com/jingweno/gh/pulls/21") assert.Equal(t, nil, err) assert.Equal(t, "jingweno", url.Owner) assert.Equal(t, "gh", url.Name) assert.Equal(t, "pulls/21", url.ProjectPath()) url, err = ParseURL("https://github.com/jingweno/gh") assert.Equal(t, nil, err) assert.Equal(t, "jingweno", url.Owner) assert.Equal(t, "gh", url.Name) assert.Equal(t, "", url.ProjectPath()) url, err = ParseURL("https://github.com/jingweno/gh/") assert.Equal(t, nil, err) assert.Equal(t, "jingweno", url.Owner) assert.Equal(t, "gh", url.Name) assert.Equal(t, "", url.ProjectPath()) } ================================================ FILE: go.mod ================================================ module github.com/github/hub/v2 go 1.11 require ( github.com/BurntSushi/toml v0.3.0 github.com/atotto/clipboard v0.0.0-20171229224153-bc5958e1c833 github.com/google/go-cmp v0.4.0 github.com/kballard/go-shellquote v0.0.0-20170619183022-cd60e84ee657 github.com/mattn/go-colorable v0.0.9 github.com/mattn/go-isatty v0.0.3 github.com/mitchellh/go-homedir v0.0.0-20161203194507-b8bc1bf76747 github.com/russross/blackfriday v0.0.0-20180526075726-670777b536d3 github.com/shurcooL/sanitized_anchor_name v0.0.0-20170918181015-86672fcb3f95 // indirect golang.org/x/crypto v0.0.0-20220314234659-1baeb1ce4c0b golang.org/x/net v0.7.0 gopkg.in/yaml.v2 v2.2.8 ) ================================================ FILE: go.sum ================================================ github.com/BurntSushi/toml v0.3.0 h1:e1/Ivsx3Z0FVTV0NSOv/aVgbUWyQuzj7DDnFblkRvsY= github.com/BurntSushi/toml v0.3.0/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/atotto/clipboard v0.0.0-20171229224153-bc5958e1c833 h1:h/E5ryZTJAtOY6T3K6u/JA1OURt0nk1C4fITywxOp4E= github.com/atotto/clipboard v0.0.0-20171229224153-bc5958e1c833/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/google/go-cmp v0.4.0 h1:xsAVV57WRhGj6kEIi8ReJzQlHHqcBYCElAvkovg3B/4= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/kballard/go-shellquote v0.0.0-20170619183022-cd60e84ee657 h1:vE7J1m7cCpiRVEIr1B5ccDxRpbPsWT5JU3if2Di5nE4= github.com/kballard/go-shellquote v0.0.0-20170619183022-cd60e84ee657/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= github.com/mattn/go-colorable v0.0.9 h1:UVL0vNpWh04HeJXV0KLcaT7r06gOH2l4OW6ddYRUIY4= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-isatty v0.0.3 h1:ns/ykhmWi7G9O+8a448SecJU3nSMBXJfqQkl0upE1jI= github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mitchellh/go-homedir v0.0.0-20161203194507-b8bc1bf76747 h1:eQox4Rh4ewJF+mqYPxCkmBAirRnPaHEB26UkNuPyjlk= github.com/mitchellh/go-homedir v0.0.0-20161203194507-b8bc1bf76747/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/russross/blackfriday v0.0.0-20180526075726-670777b536d3 h1:vZXiDtLzqEDYbeAt94qcQZ2H9SGHwbZiOFdsRT5rrng= github.com/russross/blackfriday v0.0.0-20180526075726-670777b536d3/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= github.com/shurcooL/sanitized_anchor_name v0.0.0-20170918181015-86672fcb3f95 h1:/vdW8Cb7EXrkqWGufVMES1OH2sU9gKVb2n9/1y5NMBY= github.com/shurcooL/sanitized_anchor_name v0.0.0-20170918181015-86672fcb3f95/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220314234659-1baeb1ce4c0b h1:Qwe1rC8PSniVfAFPFJeyUkB+zcysC3RgJBAGk7eqBEU= golang.org/x/crypto v0.0.0-20220314234659-1baeb1ce4c0b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0 h1:n2a8QNdAb0sZNpU9R1ALUXBbY+w51fCQDN+7EdxNBsY= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= ================================================ FILE: internal/assert/assert.go ================================================ // Package assert provides functions for testing. package assert import ( "fmt" "reflect" "testing" "github.com/google/go-cmp/cmp" ) // Equal makes the test as failed using default formatting if got is not equal to want. func Equal(t testing.TB, want, got interface{}, args ...interface{}) { t.Helper() if !reflect.DeepEqual(want, got) { msg := fmt.Sprint(args...) t.Errorf("%s\n%s", msg, cmp.Diff(want, got)) } } // NotEqual makes the test as failed using default formatting if got is equal to want. func NotEqual(t testing.TB, want, got interface{}, args ...interface{}) { t.Helper() if reflect.DeepEqual(want, got) { msg := fmt.Sprint(args...) t.Errorf("%s\nUnexpected: <%#v>", msg, want) } } // T makes the test as failed using default formatting if ok is false. func T(t testing.TB, ok bool, args ...interface{}) { t.Helper() if !ok { msg := fmt.Sprint(args...) t.Errorf("%s\nFailure", msg) } } ================================================ FILE: main.go ================================================ //go:build go1.8 // +build go1.8 package main import ( "os" "os/exec" "syscall" "github.com/github/hub/v2/commands" "github.com/github/hub/v2/github" "github.com/github/hub/v2/ui" ) func main() { defer github.CaptureCrash() err := commands.CmdRunner.Execute(os.Args) exitCode := handleError(err) os.Exit(exitCode) } func handleError(err error) int { if err == nil { return 0 } switch e := err.(type) { case *exec.ExitError: if status, ok := e.Sys().(syscall.WaitStatus); ok { return status.ExitStatus() } return 1 case *commands.ErrHelp: ui.Println(err) return 0 default: if errString := err.Error(); errString != "" { ui.Errorln(err) } return 1 } } ================================================ FILE: man-template.html ================================================ {{.Name}}({{.Section}}) - {{.Title}}
  1. {{.Name}}({{.Section}})
  2. {{.Manual}}
  3. {{.Name}}({{.Section}})

{{.Title}}

{{.Contents}}
  1. {{.Version}}
  2. {{.Date}}
================================================ FILE: md2roff/renderer.go ================================================ package md2roff import ( "bytes" "fmt" "io" "regexp" "strconv" "strings" "github.com/russross/blackfriday" ) // https://github.com/russross/blackfriday/blob/v2/markdown.go const ( ParserExtensions = blackfriday.NoIntraEmphasis | blackfriday.FencedCode | blackfriday.SpaceHeadings | blackfriday.AutoHeadingIDs | blackfriday.DefinitionLists ) var ( backslash = []byte{'\\'} enterVar = []byte("") closeVar = []byte("") tilde = []byte(`\(ti`) htmlEscape = regexp.MustCompile(`<([A-Za-z][A-Za-z0-9_-]*)>`) roffEscape = regexp.MustCompile(`[&'\_-]`) headingEscape = regexp.MustCompile(`["]`) titleRe = regexp.MustCompile(`(?P[A-Za-z][A-Za-z0-9_-]+)\((?P\d)\) -- (?P.+)`) ) func escape(src []byte, re *regexp.Regexp) []byte { return re.ReplaceAllFunc(src, func(c []byte) []byte { return append(backslash, c...) }) } func roffText(src []byte) []byte { return bytes.Replace(escape(src, roffEscape), []byte{'~'}, tilde, -1) } type RoffRenderer struct { Manual string Version string Date string Title string Name string Section uint8 listWasTerm bool } func (r *RoffRenderer) RenderHeader(buf io.Writer, ast *blackfriday.Node) { } func (r *RoffRenderer) RenderFooter(buf io.Writer, ast *blackfriday.Node) { } func (r *RoffRenderer) RenderNode(buf io.Writer, node *blackfriday.Node, entering bool) blackfriday.WalkStatus { if entering { switch node.Type { case blackfriday.Emph: io.WriteString(buf, `\fI`) case blackfriday.Strong: io.WriteString(buf, `\fB`) case blackfriday.Link: io.WriteString(buf, `\[la]`) case blackfriday.Code: io.WriteString(buf, `\fB\fC`) case blackfriday.Hardbreak: io.WriteString(buf, "\n.br\n") case blackfriday.Paragraph: if node.Parent.Type != blackfriday.Item { io.WriteString(buf, ".P\n") } else if node.Parent.FirstChild != node { io.WriteString(buf, ".sp\n") if node.Prev.Type == blackfriday.List { io.WriteString(buf, ".PP\n") } } case blackfriday.CodeBlock: io.WriteString(buf, ".PP\n.RS 4\n.nf\n") case blackfriday.Item: if node.ListFlags&blackfriday.ListTypeDefinition == 0 { if node.Parent.ListData.Tight && node.Parent.FirstChild != node { io.WriteString(buf, ".sp -1\n") } if node.Parent.ListData.Tight { io.WriteString(buf, ".IP \\(bu 2.3\n") } else { io.WriteString(buf, ".IP \\(bu 4\n") } } else { if node.ListFlags&blackfriday.ListTypeTerm != 0 { io.WriteString(buf, ".PP\n") } else { io.WriteString(buf, ".RS 4\n") } } case blackfriday.Heading: r.renderHeading(buf, node) return blackfriday.SkipChildren } } leaf := len(node.Literal) > 0 if leaf { if bytes.Equal(node.Literal, enterVar) { io.WriteString(buf, `\fI`) } else if bytes.Equal(node.Literal, closeVar) { io.WriteString(buf, `\fP`) } else { buf.Write(roffText(node.Literal)) } } if !entering || leaf { switch node.Type { case blackfriday.Emph, blackfriday.Strong: io.WriteString(buf, `\fP`) case blackfriday.Link: io.WriteString(buf, `\[ra]`) case blackfriday.Code: io.WriteString(buf, `\fR`) case blackfriday.CodeBlock: io.WriteString(buf, ".fi\n.RE\n") case blackfriday.HTMLSpan, blackfriday.Del, blackfriday.Image: case blackfriday.List: io.WriteString(buf, ".br\n") case blackfriday.Item: if node.ListFlags&blackfriday.ListTypeDefinition != 0 && node.ListFlags&blackfriday.ListTypeTerm == 0 { io.WriteString(buf, ".RE\n") } default: if !leaf { io.WriteString(buf, "\n") } } } return blackfriday.GoToNext } func textContent(node *blackfriday.Node) []byte { var buf bytes.Buffer node.Walk(func(n *blackfriday.Node, entering bool) blackfriday.WalkStatus { if entering && len(n.Literal) > 0 { buf.Write(n.Literal) } return blackfriday.GoToNext }) return buf.Bytes() } func (r *RoffRenderer) renderHeading(buf io.Writer, node *blackfriday.Node) { text := textContent(node) switch node.HeadingData.Level { case 1: var name []byte var num []byte if match := titleRe.FindAllSubmatch(text, 1); match != nil { name, num, text = match[0][1], match[0][2], match[0][3] r.Name = string(name) if sectionNum, err := strconv.Atoi(string(num)); err == nil { r.Section = uint8(sectionNum) } r.Title = string(text) } fmt.Fprintf(buf, ".TH \"%s\" \"%s\" \"%s\" \"%s\" \"%s\"\n", escape(name, headingEscape), num, escape([]byte(r.Date), headingEscape), escape([]byte(r.Version), headingEscape), escape([]byte(r.Manual), headingEscape), ) io.WriteString(buf, ".nh\n") // disable hyphenation io.WriteString(buf, ".ad l\n") // disable justification io.WriteString(buf, ".SH \"NAME\"\n") fmt.Fprintf(buf, "%s \\- %s\n", roffText(name), roffText(text), ) case 2: fmt.Fprintf(buf, ".SH \"%s\"\n", strings.ToUpper(string(escape(text, headingEscape)))) case 3: fmt.Fprintf(buf, ".SS \"%s\"\n", escape(text, headingEscape)) } } func sanitizeInput(src []byte) []byte { return htmlEscape.ReplaceAllFunc(src, func(match []byte) []byte { res := append(enterVar, match[1:len(match)-1]...) return append(res, closeVar...) }) } type renderOption struct { renderer blackfriday.Renderer buffer io.Writer } func Opt(buffer io.Writer, renderer blackfriday.Renderer) *renderOption { return &renderOption{renderer, buffer} } func Generate(src []byte, opts ...*renderOption) { parser := blackfriday.New(blackfriday.WithExtensions(ParserExtensions)) ast := parser.Parse(sanitizeInput(src)) for _, opt := range opts { opt.renderer.RenderHeader(opt.buffer, ast) ast.Walk(func(node *blackfriday.Node, entering bool) blackfriday.WalkStatus { return opt.renderer.RenderNode(opt.buffer, node, entering) }) opt.renderer.RenderFooter(opt.buffer, ast) } } ================================================ FILE: md2roff-bin/cmd.go ================================================ package main import ( "bytes" "fmt" "io" "io/ioutil" "os" "path" "regexp" "strings" "text/template" "github.com/github/hub/v2/md2roff" "github.com/github/hub/v2/utils" "github.com/russross/blackfriday" ) var ( flagManual, flagVersion, flagTemplate, flagDate string xRefRe = regexp.MustCompile(`\b(?P<name>[a-z][\w-]*)\((?P<section>\d)\)`) pageIndex map[string]bool ) func init() { pageIndex = make(map[string]bool) } type templateData struct { Contents string Manual string Date string Version string Title string Name string Section uint8 } func generateFromFile(mdFile string) error { content, err := ioutil.ReadFile(mdFile) if err != nil { return fmt.Errorf("%s (%q)", err, mdFile) } roffFile := strings.TrimSuffix(mdFile, ".md") roffBuf, err := os.OpenFile(roffFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) if err != nil { return fmt.Errorf("%s (%q)", err, roffFile) } defer roffBuf.Close() htmlFile := strings.TrimSuffix(mdFile, ".md") + ".html" htmlBuf, err := os.OpenFile(htmlFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) if err != nil { return fmt.Errorf("%s (%q)", err, htmlFile) } defer htmlBuf.Close() html := blackfriday.NewHTMLRenderer(blackfriday.HTMLRendererParameters{ Flags: blackfriday.HTMLFlagsNone, }) roff := &md2roff.RoffRenderer{ Manual: flagManual, Version: flagVersion, Date: flagDate, } htmlGenBuf := &bytes.Buffer{} var htmlWriteBuf io.Writer = htmlBuf if flagTemplate != "" { htmlWriteBuf = htmlGenBuf } md2roff.Generate(content, md2roff.Opt(roffBuf, roff), md2roff.Opt(htmlWriteBuf, html), ) if flagTemplate != "" { htmlGenBytes, err := ioutil.ReadAll(htmlGenBuf) if err != nil { return fmt.Errorf("%s [%s]", err, "htmlGenBuf") } content := "" if contentLines := strings.Split(string(htmlGenBytes), "\n"); len(contentLines) > 1 { content = strings.Join(contentLines[1:], "\n") } currentPage := fmt.Sprintf("%s(%d)", roff.Name, roff.Section) content = xRefRe.ReplaceAllStringFunc(content, func(match string) string { if match == currentPage { return match } matches := xRefRe.FindAllStringSubmatch(match, 1) fileName := fmt.Sprintf("%s.%s", matches[0][1], matches[0][2]) if pageIndex[fileName] { return fmt.Sprintf(`<a href="./%s.html">%s</a>`, fileName, match) } return match }) tmplData := templateData{ Manual: flagManual, Date: flagDate, Contents: content, Title: roff.Title, Section: roff.Section, Name: roff.Name, Version: flagVersion, } templateFile, err := ioutil.ReadFile(flagTemplate) if err != nil { return fmt.Errorf("%s (%q)", err, flagTemplate) } tmpl, err := template.New("test").Parse(string(templateFile)) if err != nil { return err } err = tmpl.Execute(htmlBuf, tmplData) if err != nil { return err } } return nil } func main() { p := utils.NewArgsParserWithUsage(` --manual NAME --version STR --template FILE --date DATE `) files, err := p.Parse(os.Args[1:]) if err != nil { panic(err) } flagManual = p.Value("--manual") flagVersion = p.Value("--version") flagTemplate = p.Value("--template") flagDate = p.Value("--date") for _, infile := range files { name := path.Base(infile) name = strings.TrimSuffix(name, ".md") pageIndex[name] = true } for _, infile := range files { err := generateFromFile(infile) if err != nil { panic(err) } } } ================================================ FILE: script/bootstrap ================================================ #!/usr/bin/env bash set -e STATUS=0 { ruby --version bundle install bundle binstub cucumber --path bin } || { echo "You need Ruby 1.9 or higher and Bundler to run hub tests" >&2 STATUS=1 } if [ $STATUS -eq 0 ]; then echo "Everything OK." fi exit $STATUS ================================================ FILE: script/build ================================================ #!/usr/bin/env bash # Usage: script/build [-o <BIN>] # script/build files set -e windows= [[ $OS == Windows* ]] && windows=1 find_source_files() { find . -maxdepth 2 -name '*.go' '!' -name '*_test.go' "$@" } build_hub() { mkdir -p "$(dirname "$1")" go build \ -ldflags "-X github.com/github/hub/v2/version.Version=`./script/version` $LDFLAGS" \ -gcflags "$GCFLAGS" \ -asmflags "$ASMFLAGS" \ -o "$1" } [ $# -gt 0 ] || set -- -o "bin/hub${windows:+.exe}" case "$1" in -o ) build_hub "${2?}" ;; files ) find_source_files ;; -h | --help ) sed -ne '/^#/!q;s/.\{1,2\}//;1d;p' < "$0" exit ;; * ) "$0" --help >&2 exit 1 esac ================================================ FILE: script/build.bat ================================================ @echo off bash script\build %* ================================================ FILE: script/changelog ================================================ #!/bin/bash # vi:ft=sh: # Usage: script/changelog [HEAD] # # Show changes to runtime files between HEAD and previous release tag. set -e current_tag="${GITHUB_REF#refs/tags/}" start_ref="HEAD" # Find the previous release on the same branch, skipping prereleases if the # current tag is a full release previous_tag="" while [[ -z $previous_tag || ( $previous_tag == *-* && $current_tag != *-* ) ]]; do previous_tag="$(git describe --tags "$start_ref"^ --abbrev=0)" start_ref="$previous_tag" done git log --first-parent --format='%C(auto,green)* %s%C(auto,reset)%n%w(0,2,2)%+b' \ --reverse "${previous_tag}.." -- `script/build files` etc share ================================================ FILE: script/coverage ================================================ #!/bin/bash set -e source_files() { script/build files | grep -vE '^\./(coverage|fixtures|version)/' } prepare() { local changed_files="$(source_files | xargs git diff --name-only --)" if [ -n "$changed_files" ]; then echo "Aborted: please commit the following files before continuing" >&2 cat <<<"$changed_files" >&2 exit 1 fi local n=0 for f in $(source_files); do go tool cover -mode=set -var="LiveCoverage$((++n))" "$f" > "$f"~ sed -E ' /^package /a\ import "github.com/github/hub/v2/coverage" s/(LiveCoverage[0-9]+)\.Count\[([0-9]+)\][^;]+/coverage.Record(\1, \2)/g ' < "$f"~ > "$f" rm "$f"~ done rm -rf "$HUB_COVERAGE" mkdir -p "${HUB_COVERAGE%/*}" } generate() { source_files | xargs git checkout -- echo 'mode: count' > "$HUB_COVERAGE"~ sed -E 's!^.+/(github.com/github/hub/v2/)!\1!' "$HUB_COVERAGE" | awk ' { a[substr($0, 0, length()-2)] += $(NF) } END { for (k in a) print k, a[k] } ' >> "$HUB_COVERAGE"~ go tool cover -func="$HUB_COVERAGE"~ > "${HUB_COVERAGE%.out}.func" if [ -z "$CI" ]; then go tool cover -html="$HUB_COVERAGE"~ -o "${HUB_COVERAGE%.out}.html" fi awk '/^total:/ { print $(NF) }' "${HUB_COVERAGE%.out}.func" } summarize() { local total_coverage local min_coverage="${1?}" total_coverage="$(generate)" echo "Code coverage: $total_coverage" local result="$(bc <<<"${total_coverage%\%} < $min_coverage")" if [ "$result" -eq 1 ]; then echo "Error: coverage dropped below the minimum threshold of ${min_coverage}%!" if [ -n "$CI" ]; then html_result="${HUB_COVERAGE%.out}.html" html_result="${html_result#$PWD/}" printf 'Please run `script/test --coverage` locally and open `%s` to analyze the results.\n' "$html_result" fi return 1 fi } cmd="${1?}" shift 1 case "$cmd" in prepare | generate | summarize ) "$cmd" "$@" ;; * ) exit 1 ;; esac ================================================ FILE: script/cross-compile ================================================ #!/usr/bin/env bash # Usage: script/cross-compile <version> # # Packages the project over a matrix of supported OS and architectures and # prints the asset filenames and labels suitable for upload. set -e version="${1?}" echo ' darwin amd64 macOS freebsd 386 FreeBSD 32-bit freebsd amd64 FreeBSD 64-bit linux 386 Linux 32-bit linux amd64 Linux 64-bit linux arm Linux ARM 32-bit linux arm64 Linux ARM 64-bit windows 386 Windows 32-bit windows amd64 Windows 64-bit ' | { while read os arch label; do [ -n "$os" ] || continue label="hub ${version} for ${label}" if ! file="$(script/package "$os" "$arch" "$version")"; then echo "packaging $label failed" >&2 continue fi printf "%s\t%s\n" "$file" "$label" done } ================================================ FILE: script/docker ================================================ #!/bin/bash # Usage: script/docker [<cucumber-args>] set -e container=hub-test workdir=/home/app/workdir docker build -t "$container" . docker run -it --rm -v "$PWD":"$workdir" -w "$workdir" "$container" \ /bin/bash -c " # Enables running WEBrick server (see local_server.rb) # https://stackoverflow.com/a/45899937/11687 cp /etc/hosts /tmp/hosts.new \ && sed -i 's/::1\\tlocalhost/::1/' /tmp/hosts.new \ && sudo cp -f /tmp/hosts.new /etc/hosts || exit 1 go test ./... bundle exec cucumber $@ " ================================================ FILE: script/get ================================================ #!/bin/bash # Usage: curl -fsSL https://github.com/github/hub/raw/master/script/get | bash -s <HUB_VERSION> # # Downloads the hub binary into `bin/hub` within the current directory. set -e latest-version() { curl -fsi https://github.com/github/hub/releases/latest | awk -F/ 'tolower($1) ~ /^location:/ {print $(NF)}' } HUB_VERSION="${1#v}" if [ -z "$HUB_VERSION" ]; then latest=$(latest-version) || true [ -n "$latest" ] || latest="v2.14.1" cat <<MSG >&2 Error: You must specify a version of hub via the first argument. Example: curl -L <script> | bash -s ${latest#v} MSG exit 1 fi ARCH="amd64" OS="$(uname -s | tr '[:upper:]' '[:lower:]')" case "$OS" in mingw* | msys* ) OS=windows ;; esac download() { case "$OS" in windows ) zip="${1%.tgz}.zip" curl -fsSLO "$zip" unzip "$(basename "$zip")" bin/hub.exe rm -f "$(basename "$zip")" ;; darwin ) curl -fsSL "$1" | tar xz --strip-components=1 '*/bin/hub' ;; * ) curl -fsSL "$1" | tar xz --strip-components=1 --wildcards '*/bin/hub' ;; esac } download "https://github.com/github/hub/releases/download/v$HUB_VERSION/hub-$OS-$ARCH-$HUB_VERSION.tgz" bin/hub version if [ -z "$GITHUB_TOKEN" ]; then cat <<MSG >&2 Warning: We recommend supplying the GITHUB_TOKEN environment variable to avoid being prompted for authentication. MSG fi ================================================ FILE: script/github-release ================================================ #!/bin/bash # Usage: script/cross-compile | script/github-release <tag> # # Takes in a list of asset filenames + labels via stdin and uploads them to the # corresponding release on GitHub. The release is created as a draft first if # missing and its body is the git changelog since the previous tagged release. set -e tag_name="${1?}" [[ $tag_name == *-* ]] && pre=1 || pre= assets=() while read -r filename label; do assets+=( -a "${filename}#${label}" ) done if hub release --include-drafts | grep -q "^${tag_name}\$"; then hub release edit "$tag_name" -m "" "${assets[@]}" else git tag --list "$tag_name" --format='%(contents:subject)%0a%0a%(contents:body)' | \ hub release create ${pre:+--prerelease} -F- "$tag_name" "${assets[@]}" fi ================================================ FILE: script/install.bat ================================================ @echo off CLS goto checkPrivileges :: Unfortunately, Windows doesn't have a decent built-in way to append a string to the $PATH. :: setx is convenient, but it 1) truncates paths longer than 1024 characters, and :: 2) mucks up the user path with the machine-wide path. :: This function takes care of these problems by calling Environment.Get/SetEnvironmentVariable :: via PowerShell, which lacks these issues. :appendToUserPath setlocal EnableDelayedExpansion set "RUNPS=powershell -NoProfile -ExecutionPolicy Bypass -Command" :: Command to start PowerShell. set "OLDPATHPS=[Environment]::GetEnvironmentVariable('PATH', 'User')" :: PowerShell command to run to get the old $PATH for the current user. :: Capture the output of %RUNPS% "%OLDPATHPS%" and set it to OLDPATH for /f "delims=" %%i in ('%RUNPS% "%OLDPATHPS%"') do ( set "OLDPATH=!OLDPATH!%%i" ) set "NEWPATH=%OLDPATH%;%1" :: Set the new $PATH %RUNPS% "[Environment]::SetEnvironmentVariable('PATH', '%NEWPATH%', 'User')" goto :eof :checkPrivileges NET FILE 1>NUL 2>NUL if '%errorlevel%' == '0' ( goto gotPrivileges ) else ( goto getPrivileges ) :getPrivileges if '%1'=='ELEV' (shift & goto gotPrivileges) echo. echo ************************************** echo Installing GitHub CLI as Administrator echo ************************************** setlocal DisableDelayedExpansion set "batchPath=%~0" setlocal EnableDelayedExpansion echo Set UAC = CreateObject^("Shell.Application"^) > "%temp%\OEgetPrivileges.vbs" echo UAC.ShellExecute "!batchPath!", "ELEV", "", "runas", 1 >> "%temp%\OEgetPrivileges.vbs" "%temp%\OEgetPrivileges.vbs" exit /B :gotPrivileges setlocal & cd /d %~dp0 set HUB_BIN_PATH="%LOCALAPPDATA%\GitHubCLI\bin" IF EXIST %HUB_BIN_PATH% GOTO DIRECTORY_EXISTS mkdir %HUB_BIN_PATH% set "path=%PATH%;%HUB_BIN_PATH:"=%" call :appendToUserPath "%HUB_BIN_PATH:"=%" :DIRECTORY_EXISTS :: Delete any existing programs 2>NUL del /q %HUB_BIN_PATH%\hub* 1>NUL copy .\bin\hub.exe %HUB_BIN_PATH%\hub.exe echo hub.exe installed successfully. Press any key to exit pause > NUL ================================================ FILE: script/install.sh ================================================ #!/usr/bin/env bash # Usage: [sudo] [prefix=/usr/local] ./install set -e case "$1" in '-h' | '--help' ) sed -ne '/^#/!q;s/.\{1,2\}//;1d;p' < "$0" exit 0 ;; esac if [[ $BASH_SOURCE == */* ]]; then cd "${BASH_SOURCE%/*}" fi prefix="${PREFIX:-$prefix}" prefix="${prefix:-/usr/local}" for src in bin/hub share/man/*/*.1 share/doc/*/*.html share/vim/vimfiles/*/*.vim; do dest="${DESTDIR}${prefix}/${src}" mkdir -p "${dest%/*}" [[ $src == share/* ]] && mode="644" || mode=755 install -m "$mode" "$src" "$dest" done ================================================ FILE: script/package ================================================ #!/usr/bin/env bash # Usage: script/package <os> <arch> <version> # # Packages the project as a release asset and prints the archive's filename. set -e os="${1?}" arch="${2?}" version="${3?}" release="hub-${os}-${arch}-${version}" case "$os" in darwin | freebsd | linux | netbsd | openbsd | solaris | windows ) ;; * ) echo "unsupported OS: $os" >&2; exit 1 ;; esac case "$arch" in 386 | amd64 | arm | arm64 ) ;; * ) echo "unsupported arch: $arch" >&2; exit 1 ;; esac export GOOS="$os" export GOARCH="$arch" tmpdir="tmp/${release}" rm -rf "$tmpdir" exename="${tmpdir}/bin/hub" [ "$os" != "windows" ] || exename="${exename}.exe" mkdir -p "${exename%/*}" script/build -o "$exename" crlf() { sed $'s/$/\r/' "$1" > "$2" } if [ "$os" = "windows" ]; then crlf README.md "${tmpdir}/README.txt" crlf LICENSE "${tmpdir}/LICENSE.txt" for man in share/doc/*/*.html; do mkdir -p "${tmpdir}/${man%/*}" cp "$man" "${tmpdir}/${man}" done crlf script/install.bat "${tmpdir}/install.bat" else cp -R README.md LICENSE etc share "$tmpdir" rm -rf "${tmpdir}/share/man/"*/*.md cp script/install.sh "${tmpdir}/install" chmod +x "${tmpdir}/install" fi if [ "$os" = "windows" ]; then file="${PWD}/${tmpdir}.zip" rm -f "$file" pushd "$tmpdir" >/dev/null zip -r "$file" * >/dev/null else file="${PWD}/${tmpdir}.tgz" rm -f "$file" pushd "${tmpdir%/*}" >/dev/null tar -czf "$file" "$release" fi echo "$file" ================================================ FILE: script/publish-release ================================================ #!/usr/bin/env bash set -e publish_documentation() { local version="$1" local doc_dir="site" local doc_branch="gh-pages" git fetch origin "${doc_branch}:${doc_branch}" git worktree add "$doc_dir" "$doc_branch" pushd "$doc_dir" git rm hub*.html >/dev/null cp ../share/doc/*/*.html . git add hub*.html GIT_COMMITTER_NAME='GitHub Actions' GIT_COMMITTER_EMAIL='noreply@github.com' \ GIT_AUTHOR_NAME='GitHub Actions' GIT_AUTHOR_EMAIL='noreply@github.com' \ git commit -m "Update documentation for $version" git push origin HEAD popd } in_default_branch() { git fetch origin master --depth 10 git merge-base --is-ancestor "$1" FETCH_HEAD } tag_name="${GITHUB_REF#refs/tags/}" make man-pages script/cross-compile "${tag_name#v}" | \ PATH="bin:$PATH" script/github-release "$tag_name" if [[ $tag_name != *-* ]] && in_default_branch "$tag_name"; then publish_documentation "$tag_name" fi ================================================ FILE: script/ruby-test ================================================ #!/usr/bin/env bash set -e if [ -z "$GITHUB_ACTIONS" ] && tmux -V; then if [ -n "$CI" ]; then git --version bash --version | head -1 zsh --version echo fi profile="all" else echo "warning: skipping shell completion tests (install tmux to enable)" >&2 profile="default" fi bin/cucumber -p "$profile" "$@" ================================================ FILE: script/tag-release ================================================ #!/bin/bash set -e version_file="version/version.go" if git diff --exit-code >/dev/null -- "$version_file"; then echo "Update the version in $version_file and try again." >&2 exit 1 fi version="$(grep -w 'Version =' "$version_file" | cut -d'"' -f2)" git commit -m "hub $version" -- "$version_file" notes_file="$(mktemp)" { echo "hub $version" echo GITHUB_REF="refs/tags/v$version" script/changelog } >"$notes_file" trap "rm -f '$notes_file'" EXIT git tag "v${version}" -F "$notes_file" --edit git push origin HEAD "v${version}" ================================================ FILE: script/test ================================================ #!/usr/bin/env bash # Usage: script/test [--coverage [<MIN>]] [<FEATURES>...] # # Run Go and Cucumber test suites for hub. set -e while [ $# -gt 0 ]; do case "$1" in --coverage ) export HUB_COVERAGE="$PWD/tmp/cover.out" if [ "${2%.*}" -gt 0 ] 2>/dev/null; then min_coverage="$2" shift 2 else min_coverage=1 shift 1 fi ;; -h | --help ) sed -ne '/^#/!q;s/.\{1,2\}//;1d;p' < "$0" exit ;; * ) break ;; esac done STATUS=0 trap "exit 1" INT check_formatting() { make fmt >/dev/null if ! git diff -U1 --exit-code; then echo echo "Some go code was not formatted properly." >&2 echo "Run \`make fmt' locally to fix these errors." >&2 return 1 fi } install_test() { mkdir -p share/doc/hub-doc touch share/man/man1/hub.1 share/doc/hub-doc/hub.1.html DESTDIR="$PWD/tmp/destdir" prefix=/my/prefix bash < script/install.sh test -x tmp/destdir/my/prefix/bin/hub test -e tmp/destdir/my/prefix/share/man/man1/hub.1 test ! -x tmp/destdir/my/prefix/share/man/man1/hub.1 test -e tmp/destdir/my/prefix/share/doc/hub-doc/hub.1.html test ! -x tmp/destdir/my/prefix/share/doc/hub-doc/hub.1.html rm share/man/man1/hub.1 share/doc/hub-doc/hub.1.html } [ -z "$HUB_COVERAGE" ] || script/coverage prepare script/build go test ./... || STATUS="$?" script/ruby-test "$@" || STATUS="$?" [ -z "$HUB_COVERAGE" ] || script/coverage summarize "$min_coverage" || STATUS="$?" if [ -n "$CI" ]; then check_formatting || STATUS="$?" install_test || STATUS="$?" fi exit "$STATUS" ================================================ FILE: script/version ================================================ #!/usr/bin/env bash # Displays hub's release version set -e if [ -n "$GITHUB_REF" ]; then echo "${GITHUB_REF#refs/tags/v}" exit fi export GIT_CEILING_DIRECTORIES=${PWD%/*} version="$(git describe --tags HEAD 2>/dev/null || true)" if [ -z "$version" ]; then version="$(grep 'Version =' version/version.go | head -1 | cut -d '"' -f2)" sha="$(git rev-parse --short HEAD 2>/dev/null || true)" [ -z "$sha" ] || version="${version}-g${sha}" fi echo "${version#v}" ================================================ FILE: script/version.bat ================================================ @echo off bash script\version %* ================================================ FILE: share/vim/vimfiles/ftdetect/pullrequest.vim ================================================ autocmd BufNewFile,BufRead PULLREQ_EDITMSG set filetype=pullrequest ================================================ FILE: share/vim/vimfiles/syntax/pullrequest.vim ================================================ " Vim syntax file " Language: Hub Pull Request " Maintainer: Derek Sifford <dereksifford@gmail.com> " Filenames: *.git/PULLREQ_EDITMSG " Latest Revision: 2018 Oct 30 if exists('b:current_syntax') finish endif syn case match syn include @Markdown syntax/markdown.vim syn match pullreqBlank contained "^.*" contains=@Spell syn match pullreqOverflow contained ".*" contains=@Spell syn match pullreqSummary contained "^.\{0,50\}" contains=@Spell nextgroup=pullreqOverflow syn match pullreqMetaHeader contained "^Changes:" syn match pullreqSha contained "^[a-z0-9]\{7\}\ze (" nextgroup=pullreqCommitMeta syn match pullreqCommitMeta contained skipnl skipwhite ".*" nextgroup=pullreqCommitMessage syn match pullreqCommitMessage contained "^\s*\zs.*" syn match pullreqBranchInfo contained "\S\+:\S\+" syn region pullreqBranchInfoLine contained transparent start="^Requesting a pull" end="$" contains=pullreqBranchInfo syn region pullreqMessage keepend start="^." end="^\ze# [-]* >8" contains=@Markdown,@Spell nextgroup=pullreqMetadata syn region pullreqMetadata fold start="^# [-]* >8 [-]*$" end="\%$" contains=pullreqMetaHeader,pullreqSha,pullreqBranchInfoLine syn match pullreqFirstLine skipnl "\%^[^#].*" contains=pullreqSummary nextgroup=pullreqBlank hi def link pullreqBlank Error hi def link pullreqBranchInfo Keyword hi def link pullreqCommitMessage String hi def link pullreqMetaHeader htmlH1 hi def link pullreqMetadata Comment hi def link pullreqSha Constant hi def link pullreqSummary Keyword let b:current_syntax = 'pullrequest' ================================================ FILE: ui/format.go ================================================ package ui import ( "fmt" "regexp" "strconv" "strings" ) // Expand expands a format string using `git log` message syntax. func Expand(format string, values map[string]string, colorize bool) string { f := &expander{values: values, colorize: colorize} return f.Expand(format) } // An expander is a stateful helper to expand a format string. type expander struct { // formatted holds the parts of the string that have already been formatted. formatted []string // values is the map of values that should be expanded. values map[string]string // colorize is a flag to indicate whether to use colors. colorize bool // skipNext is true if the next placeholder is not a placeholder and can be // output directly as such. skipNext bool // padNext is an object that should be used to pad the next placeholder. padNext *padder } func (f *expander) Expand(format string) string { parts := strings.Split(format, "%") f.formatted = make([]string, 0, len(parts)) f.append(parts[0]) for _, p := range parts[1:] { v, t := f.expandOneVar(p) f.append(v, t) } return f.crush() } func (f *expander) append(formattedText ...string) { f.formatted = append(f.formatted, formattedText...) } func (f *expander) crush() string { s := strings.Join(f.formatted, "") f.formatted = nil return s } var colorMap = map[string]string{ "black": "30", "red": "31", "green": "32", "yellow": "33", "blue": "34", "magenta": "35", "cyan": "36", "white": "37", "reset": "", } func (f *expander) expandOneVar(format string) (expand string, untouched string) { if f.skipNext { f.skipNext = false return "", format } if format == "" { f.skipNext = true return "", "%" } if f.padNext != nil { p := f.padNext f.padNext = nil e, u := f.expandOneVar(format) return f.pad(e, p), u } if e, u, ok := f.expandSpecialChar(format[0], format[1:]); ok { return e, u } if f.values != nil { for i := 1; i <= len(format); i++ { if v, exists := f.values[format[0:i]]; exists { return v, format[i:] } } } return "", "%" + format } func (f *expander) expandSpecialChar(firstChar byte, format string) (expand string, untouched string, wasExpanded bool) { switch firstChar { case 'n': return "\n", format, true case 'C': for k, v := range colorMap { if strings.HasPrefix(format, k) { if f.colorize { return "\033[" + v + "m", format[len(k):], true } return "", format[len(k):], true } } // TODO: Add custom color as specified in color.branch.* options. // TODO: Handle auto-coloring. case 'x': if len(format) >= 2 { if v, err := strconv.ParseInt(format[:2], 16, 32); err == nil { return fmt.Sprintf("%c", v), format[2:], true } } case '+': e, u := f.expandOneVar(format) if e != "" { return "\n" + e, u, true } return "", u, true case ' ': e, u := f.expandOneVar(format) if e != "" { return " " + e, u, true } return "", u, true case '-': e, u := f.expandOneVar(format) if e != "" { return e, u, true } f.append(strings.TrimRight(f.crush(), "\n")) return "", u, true case '<', '>': if m := paddingPattern.FindStringSubmatch(string(firstChar) + format); len(m) == 7 { if p := padderFromConfig(m[1], m[2], m[3], m[4], m[5]); p != nil { f.padNext = p return "", m[6], true } } } return "", "", false } func (f *expander) pad(s string, p *padder) string { size := int(p.size) if p.sizeAsColumn { previous := f.crush() f.append(previous) size -= len(previous) - strings.LastIndex(previous, "\n") - 1 } numPadding := size - len(s) if numPadding == 0 { return s } if numPadding < 0 { if p.usePreviousSpace { previous := f.crush() noBlanks := strings.TrimRight(previous, " ") f.append(noBlanks) numPadding += len(previous) - len(noBlanks) } if numPadding <= 0 { return p.truncate(s, -numPadding) } } switch p.orientation { case padLeft: return strings.Repeat(" ", numPadding) + s case padMiddle: return strings.Repeat(" ", numPadding/2) + s + strings.Repeat(" ", (numPadding+1)/2) } // Pad right by default. return s + strings.Repeat(" ", numPadding) } type paddingOrientation int const ( padRight paddingOrientation = iota padLeft padMiddle ) type truncingMethod int const ( truncLeft truncingMethod = iota truncRight truncMiddle ) type padder struct { orientation paddingOrientation size int64 sizeAsColumn bool usePreviousSpace bool truncing truncingMethod } var paddingPattern = regexp.MustCompile(`^(>)?([><])(\|)?\((\d+)(,[rm]?trunc)?\)(.*)$`) func padderFromConfig(alsoLeft, orientation, asColumn, size, trunc string) *padder { p := &padder{} if orientation == ">" { p.orientation = padLeft } else if alsoLeft == "" { p.orientation = padRight } else { p.orientation = padMiddle } p.sizeAsColumn = asColumn != "" var err error if p.size, err = strconv.ParseInt(size, 10, 64); err != nil { return nil } p.usePreviousSpace = alsoLeft != "" && p.orientation == padLeft switch trunc { case ",trunc": p.truncing = truncLeft case ",rtrunc": p.truncing = truncRight case ",mtrunc": p.truncing = truncMiddle } return p } func (p *padder) truncate(s string, numReduce int) string { if numReduce == 0 { return s } numLeft := len(s) - numReduce - 2 if numLeft < 0 { numLeft = 0 } switch p.truncing { case truncRight: return ".." + s[len(s)-numLeft:] case truncMiddle: return s[:numLeft/2] + ".." + s[len(s)-(numLeft+1)/2:] } // Trunc left by default. return s[:numLeft] + ".." } ================================================ FILE: ui/format_test.go ================================================ package ui import ( "testing" ) type expanderTest struct { name string format string values map[string]string colorize bool expect string } func testExpander(t *testing.T, tests []expanderTest) { for _, test := range tests { if got := Expand(test.format, test.values, test.colorize); got != test.expect { t.Errorf("%s: Expand(%q, ...) = %q, want %q", test.name, test.format, got, test.expect) } } } func TestExpand(t *testing.T) { testExpander(t, []expanderTest{ { name: "Simple example", format: "The author of %h was %an, %ar%nThe title was >>%s<<%n", values: map[string]string{ "h": "fe6e0ee", "an": "Junio C Hamano", "ar": "23 hours ago", "s": "t4119: test autocomputing -p<n> for traditional diff input.", }, expect: "The author of fe6e0ee was Junio C Hamano, 23 hours ago\nThe title was >>t4119: test autocomputing -p<n> for traditional diff input.<<\n", }, { name: "Percent sign, middle and trailing", format: "%%a %%b %", values: map[string]string{"a": "A variable that should not be used."}, expect: "%a %b %", }, { name: "Colors", format: "%Cred%r %Cgreen%g %Cblue%b%Creset normal", values: map[string]string{"r": "RED", "g": "GREEN", "b": "BLUE"}, colorize: true, expect: "\033[31mRED \033[32mGREEN \033[34mBLUE\033[m normal", }, { name: "Colors not colorized", format: "%Cred%r %Cgreen%g %Cblue%b%Creset normal", values: map[string]string{"r": "RED", "g": "GREEN", "b": "BLUE"}, colorize: false, expect: "RED GREEN BLUE normal", }, { name: "Byte from hex code", format: "%x00 %x3712%x61 %x%x1%xga", expect: "\x00 \x3712a %x%x1%xga", }, }) } func TestExpand_Modifiers(t *testing.T) { testExpander(t, []expanderTest{ { name: "plus modifier, conditional line", format: "line1%+a line2%+b line3", values: map[string]string{"a": "A", "b": ""}, expect: "line1\nA line2 line3", }, { name: "blank modifier, conditional blank", format: "word1% a word2% b word3", values: map[string]string{"a": "A", "b": ""}, expect: "word1 A word2 word3", }, { name: "minus modifier, crush preceding line-feeds", format: "word1%n%n%-a", values: map[string]string{"a": ""}, expect: "word1", }, }) } func TestExpand_Padding(t *testing.T) { testExpander(t, []expanderTest{ { name: "padding", format: "%<(10)%a", values: map[string]string{"a": "012"}, expect: "012 ", }, { name: "padding, wrong number", format: "%<(1a)%a", values: map[string]string{"a": "012"}, expect: "%<(1a)012", }, { name: "padding left", format: "%>(10)%a", values: map[string]string{"a": "012"}, expect: " 012", }, { name: "padding middle", format: "%><(10)%a", values: map[string]string{"a": "0123"}, expect: " 0123 ", }, { name: "padding middle (odd # of blanks)", format: "%><(10)%a", values: map[string]string{"a": "012"}, expect: " 012 ", }, { name: "padding uses extra blank on the left", format: "%>>(5)| %a", values: map[string]string{"a": "0123456"}, expect: "| 0123456", }, { name: "padding until column N", format: "%>|(10)abcdef%a", values: map[string]string{"a": "012"}, expect: "abcdef 012", }, }) } func TestExpand_Truncing(t *testing.T) { testExpander(t, []expanderTest{ { name: "truncing", format: "%>(5,trunc)%a", values: map[string]string{"a": "0123456"}, expect: "012..", }, { name: "truncing on the right", format: "%>(5,rtrunc)%a", values: map[string]string{"a": "0123456"}, expect: "..456", }, { name: "truncing in the middle", format: "%>(6,mtrunc)%a", values: map[string]string{"a": "0123456"}, expect: "01..56", }, { name: "truncing in the middle (odd # of chars)", format: "%>(5,mtrunc)%a", values: map[string]string{"a": "0123456"}, expect: "0..56", }, { name: "truncing not enough space", format: "%>(1,trunc)%a", values: map[string]string{"a": "0123456"}, expect: "..", }, { name: "truncing but use extra blanks on the left", format: "%>>(3,trunc)| %a", values: map[string]string{"a": "0123456"}, expect: "|0123..", }, }) } ================================================ FILE: ui/ui.go ================================================ package ui import ( "fmt" "io" "os" "github.com/mattn/go-colorable" "github.com/mattn/go-isatty" ) type UI interface { Print(a ...interface{}) (n int, err error) Printf(format string, a ...interface{}) (n int, err error) Println(a ...interface{}) (n int, err error) Errorf(format string, a ...interface{}) (n int, err error) Errorln(a ...interface{}) (n int, err error) } var ( Stdout = colorable.NewColorableStdout() Stderr = colorable.NewColorableStderr() Default UI = Console{Stdout: Stdout, Stderr: Stderr} ) func Print(a ...interface{}) (n int) { n, err := Default.Print(a...) if err != nil { // If something as basic as printing to stdout fails, just panic and exit os.Exit(1) } return } func Printf(format string, a ...interface{}) (n int) { n, err := Default.Printf(format, a...) if err != nil { // If something as basic as printing to stdout fails, just panic and exit os.Exit(1) } return } func Println(a ...interface{}) (n int) { n, err := Default.Println(a...) if err != nil { // If something as basic as printing to stdout fails, just panic and exit os.Exit(1) } return } func Errorf(format string, a ...interface{}) (n int) { n, err := Default.Errorf(format, a...) if err != nil { // If something as basic as printing to stderr fails, just panic and exit os.Exit(1) } return } func Errorln(a ...interface{}) (n int) { n, err := Default.Errorln(a...) if err != nil { // If something as basic as printing to stderr fails, just panic and exit os.Exit(1) } return } func IsTerminal(f *os.File) bool { return isatty.IsTerminal(f.Fd()) } type Console struct { Stdout io.Writer Stderr io.Writer } func (c Console) Print(a ...interface{}) (n int, err error) { return fmt.Fprint(c.Stdout, a...) } func (c Console) Printf(format string, a ...interface{}) (n int, err error) { return fmt.Fprintf(c.Stdout, format, a...) } func (c Console) Println(a ...interface{}) (n int, err error) { return fmt.Fprintln(c.Stdout, a...) } func (c Console) Errorf(format string, a ...interface{}) (n int, err error) { return fmt.Fprintf(c.Stderr, format, a...) } func (c Console) Errorln(a ...interface{}) (n int, err error) { return fmt.Fprintln(c.Stderr, a...) } ================================================ FILE: utils/args_parser.go ================================================ package utils import ( "fmt" "regexp" "strconv" "strings" ) type argsFlag struct { expectsValue bool values []string } func (f *argsFlag) addValue(v string) { f.values = append(f.values, v) } func (f *argsFlag) lastValue() string { l := len(f.values) if l > 0 { return f.values[l-1] } return "" } func (f *argsFlag) reset() { if len(f.values) > 0 { f.values = []string{} } } type ArgsParser struct { flagMap map[string]*argsFlag flagAliases map[string]string PositionalIndices []int HasTerminated bool } func (p *ArgsParser) Parse(args []string) ([]string, error) { var flagName string var flagValue string var hasFlagValue bool var i int var arg string p.HasTerminated = false for _, f := range p.flagMap { f.reset() } if len(p.PositionalIndices) > 0 { p.PositionalIndices = []int{} } positional := []string{} var parseError error logError := func(f string, p ...interface{}) { if parseError == nil { parseError = fmt.Errorf(f, p...) } } acknowledgeFlag := func() bool { canonicalFlagName := flagName if n, found := p.flagAliases[flagName]; found { canonicalFlagName = n } f := p.flagMap[canonicalFlagName] if f == nil { if len(flagName) == 2 { logError("unknown shorthand flag: '%s' in %s", flagName[1:], arg) } else { logError("unknown flag: '%s'", flagName) } return true } if f.expectsValue { if !hasFlagValue { i++ if i < len(args) { flagValue = args[i] } else { logError("no value given for '%s'", flagName) return true } } } else if hasFlagValue && len(flagName) <= 2 { flagValue = "" } f.addValue(flagValue) return f.expectsValue } for i = 0; i < len(args); i++ { arg = args[i] if p.HasTerminated || len(arg) == 0 || arg == "-" { } else if arg == "--" { if !p.HasTerminated { p.HasTerminated = true continue } } else if strings.HasPrefix(arg, "--") { flagName = arg flagValue = "" eq := strings.IndexByte(arg, '=') hasFlagValue = eq >= 0 if hasFlagValue { flagName = arg[:eq] flagValue = arg[eq+1:] } acknowledgeFlag() continue } else if arg[0] == '-' { for j := 1; j < len(arg); j++ { flagName = "-" + arg[j:j+1] flagValue = "" hasFlagValue = j+1 < len(arg) if hasFlagValue { flagValue = arg[j+1:] } if acknowledgeFlag() { break } } continue } p.PositionalIndices = append(p.PositionalIndices, i) positional = append(positional, arg) } return positional, parseError } func (p *ArgsParser) RegisterValue(name string, aliases ...string) { f := &argsFlag{expectsValue: true} p.flagMap[name] = f for _, alias := range aliases { p.flagAliases[alias] = name } } func (p *ArgsParser) RegisterBool(name string, aliases ...string) { f := &argsFlag{expectsValue: false} p.flagMap[name] = f for _, alias := range aliases { p.flagAliases[alias] = name } } func (p *ArgsParser) Value(name string) string { if f, found := p.flagMap[name]; found { return f.lastValue() } return "" } func (p *ArgsParser) AllValues(name string) []string { if f, found := p.flagMap[name]; found { return f.values } return []string{} } func (p *ArgsParser) Bool(name string) bool { if f, found := p.flagMap[name]; found { return len(f.values) > 0 && f.lastValue() != "false" } return false } func (p *ArgsParser) Int(name string) int { i, _ := strconv.Atoi(p.Value(name)) return i } func (p *ArgsParser) HasReceived(name string) bool { f, found := p.flagMap[name] return found && len(f.values) > 0 } func NewArgsParser() *ArgsParser { return &ArgsParser{ flagMap: make(map[string]*argsFlag), flagAliases: make(map[string]string), } } func NewArgsParserWithUsage(usage string) *ArgsParser { p := NewArgsParser() f := `(-[a-zA-Z0-9@^]|--[a-z][a-z0-9-]+)(?:\[?[ =]([a-zA-Z_<>:=-]+\]?))?` re := regexp.MustCompile(fmt.Sprintf(`(?m)^\s*%s(?:,\s*%s)?$`, f, f)) for _, match := range re.FindAllStringSubmatch(usage, -1) { n1 := match[1] n2 := match[3] hasValue := !(match[2] == "" || strings.HasSuffix(match[2], "]")) || match[4] != "" var aliases []string if len(n1) == 2 && len(n2) > 2 { aliases = []string{n1} n1 = n2 } else if n2 != "" { aliases = []string{n2} } if hasValue { p.RegisterValue(n1, aliases...) } else { p.RegisterBool(n1, aliases...) } } return p } ================================================ FILE: utils/args_parser_test.go ================================================ package utils import ( "errors" "reflect" "testing" ) func equal(t *testing.T, expected, got interface{}) { t.Helper() if !reflect.DeepEqual(expected, got) { t.Errorf("expected: %#v, got: %#v", expected, got) } } func TestArgsParser(t *testing.T) { p := NewArgsParser() p.RegisterValue("--hello", "-e") p.RegisterValue("--origin", "-o") args := []string{"--hello", "world", "one", "--", "--two"} rest, err := p.Parse(args) equal(t, nil, err) equal(t, []string{"one", "--two"}, rest) equal(t, "world", p.Value("--hello")) equal(t, true, p.HasReceived("--hello")) equal(t, "", p.Value("-e")) equal(t, false, p.HasReceived("-e")) equal(t, "", p.Value("--origin")) equal(t, false, p.HasReceived("--origin")) equal(t, []int{2, 4}, p.PositionalIndices) } func TestArgsParser_RepeatedInvocation(t *testing.T) { p := NewArgsParser() p.RegisterValue("--hello", "-e") p.RegisterValue("--origin", "-o") rest, err := p.Parse([]string{"--hello", "world", "--", "one"}) equal(t, nil, err) equal(t, []string{"one"}, rest) equal(t, []int{3}, p.PositionalIndices) equal(t, true, p.HasReceived("--hello")) equal(t, "world", p.Value("--hello")) equal(t, false, p.HasReceived("--origin")) equal(t, true, p.HasTerminated) rest, err = p.Parse([]string{"two", "-oupstream"}) equal(t, nil, err) equal(t, []string{"two"}, rest) equal(t, []int{0}, p.PositionalIndices) equal(t, false, p.HasReceived("--hello")) equal(t, true, p.HasReceived("--origin")) equal(t, "upstream", p.Value("--origin")) equal(t, false, p.HasTerminated) } func TestArgsParser_UnknownFlag(t *testing.T) { p := NewArgsParser() p.RegisterValue("--hello") p.RegisterBool("--yes", "-y") args := []string{"--hello", "world", "--nonexist", "one", "--", "--two"} rest, err := p.Parse(args) equal(t, errors.New("unknown flag: '--nonexist'"), err) equal(t, []string{"one", "--two"}, rest) rest, err = p.Parse([]string{"one", "-yelp"}) equal(t, errors.New("unknown shorthand flag: 'e' in -yelp"), err) equal(t, []string{"one"}, rest) equal(t, true, p.Bool("--yes")) } func TestArgsParser_BlankArgs(t *testing.T) { p := NewArgsParser() rest, err := p.Parse([]string{"", ""}) equal(t, nil, err) equal(t, []string{"", ""}, rest) equal(t, []int{0, 1}, p.PositionalIndices) } func TestArgsParser_Values(t *testing.T) { p := NewArgsParser() p.RegisterValue("--origin", "-o") args := []string{"--origin=a=b", "--origin=", "--origin", "c", "-o"} rest, err := p.Parse(args) equal(t, errors.New("no value given for '-o'"), err) equal(t, []string{}, rest) equal(t, []string{"a=b", "", "c"}, p.AllValues("--origin")) } func TestArgsParser_Bool(t *testing.T) { p := NewArgsParser() p.RegisterBool("--noop") p.RegisterBool("--color") p.RegisterBool("--draft", "-d") args := []string{"-d", "--draft=false", "--color=auto"} rest, err := p.Parse(args) equal(t, nil, err) equal(t, []string{}, rest) equal(t, false, p.Bool("--draft")) equal(t, true, p.HasReceived("--draft")) equal(t, false, p.HasReceived("-d")) equal(t, false, p.HasReceived("--noop")) equal(t, false, p.Bool("--noop")) equal(t, true, p.HasReceived("--color")) equal(t, "auto", p.Value("--color")) } func TestArgsParser_BoolValue(t *testing.T) { p := NewArgsParser() p.RegisterBool("--draft") args := []string{"--draft=yes pls"} rest, err := p.Parse(args) equal(t, nil, err) equal(t, []string{}, rest) equal(t, true, p.HasReceived("--draft")) equal(t, true, p.Bool("--draft")) equal(t, "yes pls", p.Value("--draft")) } func TestArgsParser_BoolValue_multiple(t *testing.T) { p := NewArgsParser() p.RegisterBool("--draft") p.RegisterBool("--prerelease") args := []string{"--draft=false", "--prerelease"} rest, err := p.Parse(args) equal(t, nil, err) equal(t, []string{}, rest) equal(t, false, p.Bool("--draft")) equal(t, true, p.Bool("--prerelease")) } func TestArgsParser_Shorthand(t *testing.T) { p := NewArgsParser() p.RegisterValue("--origin", "-o") p.RegisterBool("--draft", "-d") p.RegisterBool("--copy", "-c") args := []string{"-co", "one", "-dotwo"} rest, err := p.Parse(args) equal(t, nil, err) equal(t, []string{}, rest) equal(t, []string{"one", "two"}, p.AllValues("--origin")) equal(t, true, p.Bool("--draft")) equal(t, true, p.Bool("--copy")) } func TestArgsParser_ShorthandEdgeCase(t *testing.T) { p := NewArgsParser() p.RegisterBool("--draft", "-d") p.RegisterBool("-f") p.RegisterBool("-a") p.RegisterBool("-l") p.RegisterBool("-s") p.RegisterBool("-e") args := []string{"-dfalse"} rest, err := p.Parse(args) equal(t, nil, err) equal(t, []string{}, rest) equal(t, true, p.Bool("--draft")) } func TestArgsParser_Dashes(t *testing.T) { p := NewArgsParser() p.RegisterValue("--file", "-F") args := []string{"-F-", "-", "--", "-F", "--"} rest, err := p.Parse(args) equal(t, nil, err) equal(t, []string{"-", "-F", "--"}, rest) equal(t, "-", p.Value("--file")) } func TestArgsParser_RepeatedArg(t *testing.T) { p := NewArgsParser() p.RegisterValue("--msg", "-m") args := []string{"--msg=hello", "-m", "world", "--msg", "how", "-mare you?"} rest, err := p.Parse(args) equal(t, nil, err) equal(t, []string{}, rest) equal(t, "are you?", p.Value("--msg")) equal(t, []string{"hello", "world", "how", "are you?"}, p.AllValues("--msg")) } func TestArgsParser_Int(t *testing.T) { p := NewArgsParser() p.RegisterValue("--limit", "-L") p.RegisterValue("--depth", "-d") args := []string{"-L24", "-d", "-3"} rest, err := p.Parse(args) equal(t, nil, err) equal(t, []string{}, rest) equal(t, true, p.HasReceived("--limit")) equal(t, 24, p.Int("--limit")) equal(t, true, p.HasReceived("--depth")) equal(t, -3, p.Int("--depth")) } func TestArgsParser_WithUsage(t *testing.T) { p := NewArgsParserWithUsage(` -L, --limit N retrieve at most N records -d, --draft save as draft --message=<msg>, -m <msg> set message body `) args := []string{"-L24", "-d", "-mhello"} rest, err := p.Parse(args) equal(t, nil, err) equal(t, []string{}, rest) equal(t, "24", p.Value("--limit")) equal(t, true, p.Bool("--draft")) equal(t, "hello", p.Value("--message")) } ================================================ FILE: utils/color.go ================================================ package utils import ( "fmt" "math" "os" "strconv" ) var ( Black *Color White *Color ) func init() { initColorCube() Black, _ = NewColor("000000") White, _ = NewColor("ffffff") } type Color struct { Red uint8 Green uint8 Blue uint8 } func NewColor(hex string) (*Color, error) { red, err := strconv.ParseUint(hex[0:2], 16, 8) if err != nil { return nil, err } green, err := strconv.ParseUint(hex[2:4], 16, 8) if err != nil { return nil, err } blue, err := strconv.ParseUint(hex[4:6], 16, 8) if err != nil { return nil, err } return &Color{ Red: uint8(red), Green: uint8(green), Blue: uint8(blue), }, nil } func (c *Color) Distance(other *Color) float64 { return math.Sqrt(math.Pow(float64(c.Red-other.Red), 2) + math.Pow(float64(c.Green-other.Green), 2) + math.Pow(float64(c.Blue-other.Blue), 2)) } func rgbComponentToBoldValue(component uint8) float64 { srgb := float64(component) / 255 if srgb <= 0.03928 { return srgb / 12.92 } return math.Pow(((srgb + 0.055) / 1.055), 2.4) } func (c *Color) Luminance() float64 { return 0.2126*rgbComponentToBoldValue(c.Red) + 0.7152*rgbComponentToBoldValue(c.Green) + 0.0722*rgbComponentToBoldValue(c.Blue) } func (c *Color) ContrastRatio(other *Color) float64 { L := c.Luminance() otherL := other.Luminance() var L1, L2 float64 if L > otherL { L1, L2 = L, otherL } else { L1, L2 = otherL, L } ratio := (L1 + 0.05) / (L2 + 0.05) return ratio } var x6colorIndexes = [6]uint8{0, 95, 135, 175, 215, 255} var x6colorCube [216]Color func initColorCube() { i := 0 for iR := 0; iR < 6; iR++ { for iG := 0; iG < 6; iG++ { for iB := 0; iB < 6; iB++ { x6colorCube[i] = Color{ x6colorIndexes[iR], x6colorIndexes[iG], x6colorIndexes[iB], } i++ } } } } func ditherTo256ColorCode(color *Color) (code int) { iMatch := -1 minDistance := float64(99999) for i := 0; i < 216; i++ { distance := color.Distance(&x6colorCube[i]) if distance < minDistance { iMatch = i minDistance = distance } } return iMatch + 16 } var non24bitColorTerms = []string{ "Apple_Terminal", } var isTerm24bitColorCapableCache bool var isTerm24bitColorCapableCacheIsInit bool = false func isTerm24bitColorCapable() bool { if !isTerm24bitColorCapableCacheIsInit { isTerm24bitColorCapableCache = true myTermProg := os.Getenv("TERM_PROGRAM") for _, brokenTerm := range non24bitColorTerms { if myTermProg == brokenTerm { isTerm24bitColorCapableCache = false break } } isTerm24bitColorCapableCacheIsInit = true } return isTerm24bitColorCapableCache } func RgbToTermColorCode(color *Color) string { if isTerm24bitColorCapable() { return fmt.Sprintf("2;%d;%d;%d", color.Red, color.Green, color.Blue) } intCode := ditherTo256ColorCode(color) return fmt.Sprintf("5;%d", intCode) } ================================================ FILE: utils/json.go ================================================ package utils import ( "encoding/json" "fmt" "io" "strings" ) type state struct { isObject bool isArray bool arrayIndex int objectKey string parentState *state } func stateKey(s *state) string { k := "" if s.parentState != nil { k = stateKey(s.parentState) } if s.isObject { return fmt.Sprintf("%s.%s", k, s.objectKey) } else if s.isArray { return fmt.Sprintf("%s.[%d]", k, s.arrayIndex) } else { return k } } func JSONPath(out io.Writer, src io.Reader, colorize bool) (hasNextPage bool, endCursor string) { dec := json.NewDecoder(src) dec.UseNumber() s := &state{} postEmit := func() { if s.isObject { s.objectKey = "" } else if s.isArray { s.arrayIndex++ } } color := func(c string, t interface{}) string { if colorize { return fmt.Sprintf("\033[%sm%s\033[m", c, t) } else if tt, ok := t.(string); ok { return tt } else { return fmt.Sprintf("%s", t) } } for { token, err := dec.Token() if err == io.EOF { break } else if err != nil { panic(err) } if delim, ok := token.(json.Delim); ok { switch delim { case '{': s = &state{isObject: true, parentState: s} case '[': s = &state{isArray: true, parentState: s} case '}', ']': s = s.parentState postEmit() default: panic("unknown delim") } } else { if s.isObject && s.objectKey == "" { s.objectKey = token.(string) } else { k := stateKey(s) fmt.Fprintf(out, "%s\t", color("0;36", k)) switch tt := token.(type) { case string: fmt.Fprintf(out, "%s\n", strings.Replace(tt, "\n", "\\n", -1)) if strings.HasSuffix(k, ".pageInfo.endCursor") { endCursor = tt } case json.Number: fmt.Fprintf(out, "%s\n", color("0;35", tt)) case nil: fmt.Fprintf(out, "\n") case bool: fmt.Fprintf(out, "%s\n", color("1;33", fmt.Sprintf("%v", tt))) if strings.HasSuffix(k, ".pageInfo.hasNextPage") { hasNextPage = tt } default: panic("unknown type") } postEmit() } } } return } ================================================ FILE: utils/utils.go ================================================ package utils import ( "errors" "fmt" "os" "os/exec" "path/filepath" "runtime" "strings" "time" "github.com/github/hub/v2/ui" "github.com/kballard/go-shellquote" ) var timeNow = time.Now func Check(err error) { if err != nil { ui.Errorln(err) os.Exit(1) } } func ConcatPaths(paths ...string) string { return strings.Join(paths, "/") } func BrowserLauncher() ([]string, error) { browser := os.Getenv("BROWSER") if browser == "" { browser = searchBrowserLauncher(runtime.GOOS) } else { browser = os.ExpandEnv(browser) } if browser == "" { return nil, errors.New("Please set $BROWSER to a web launcher") } return shellquote.Split(browser) } func searchBrowserLauncher(goos string) (browser string) { switch goos { case "darwin": browser = "open" case "windows": browser = "cmd /c start" default: candidates := []string{"xdg-open", "cygstart", "x-www-browser", "firefox", "opera", "mozilla", "netscape"} for _, b := range candidates { path, err := exec.LookPath(b) if err == nil { browser = path break } } } return browser } func CommandPath(cmd string) (string, error) { if runtime.GOOS == "windows" && !strings.HasSuffix(cmd, ".exe") { cmd = cmd + ".exe" } path, err := exec.LookPath(cmd) if err != nil { return "", err } path, err = filepath.Abs(path) if err != nil { return "", err } return filepath.EvalSymlinks(path) } func TimeAgo(t time.Time) string { duration := timeNow().Sub(t) minutes := duration.Minutes() hours := duration.Hours() days := hours / 24 months := days / 30 years := months / 12 var val int var unit string if minutes < 1 { return "now" } else if hours < 1 { val = int(minutes) unit = "minute" } else if days < 1 { val = int(hours) unit = "hour" } else if months < 1 { val = int(days) unit = "day" } else if years < 1 { val = int(months) unit = "month" } else { val = int(years) unit = "year" } var plural string if val > 1 { plural = "s" } return fmt.Sprintf("%d %s%s ago", val, unit, plural) } ================================================ FILE: utils/utils_test.go ================================================ package utils import ( "github.com/github/hub/v2/internal/assert" "testing" "time" ) func TestSearchBrowserLauncher(t *testing.T) { browser := searchBrowserLauncher("darwin") assert.Equal(t, "open", browser) browser = searchBrowserLauncher("windows") assert.Equal(t, "cmd /c start", browser) } func TestConcatPaths(t *testing.T) { assert.Equal(t, "foo/bar/baz", ConcatPaths("foo", "bar", "baz")) } func TestTimeAgo(t *testing.T) { timeNow = func() time.Time { return time.Date(2018, 10, 28, 14, 34, 58, 651387237, time.UTC) } now := timeNow() secAgo := now.Add(-1 * time.Second) actual := TimeAgo(secAgo) assert.Equal(t, "now", actual) minAgo := now.Add(-1 * time.Minute) actual = TimeAgo(minAgo) assert.Equal(t, "1 minute ago", actual) minsAgo := now.Add(-5 * time.Minute) actual = TimeAgo(minsAgo) assert.Equal(t, "5 minutes ago", actual) hourAgo := now.Add(-1 * time.Hour) actual = TimeAgo(hourAgo) assert.Equal(t, "1 hour ago", actual) hoursAgo := now.Add(-3 * time.Hour) actual = TimeAgo(hoursAgo) assert.Equal(t, "3 hours ago", actual) dayAgo := now.Add(-1 * 24 * time.Hour) actual = TimeAgo(dayAgo) assert.Equal(t, "1 day ago", actual) daysAgo := now.Add(-5 * 24 * time.Hour) actual = TimeAgo(daysAgo) assert.Equal(t, "5 days ago", actual) monthAgo := now.Add(-1 * 24 * 31 * time.Hour) actual = TimeAgo(monthAgo) assert.Equal(t, "1 month ago", actual) monthsAgo := now.Add(-2 * 24 * 31 * time.Hour) actual = TimeAgo(monthsAgo) assert.Equal(t, "2 months ago", actual) yearAgo := now.Add(-1 * 24 * 31 * 12 * time.Hour) actual = TimeAgo(yearAgo) assert.Equal(t, "1 year ago", actual) yearsAgo := now.Add(-2 * 24 * 31 * 12 * time.Hour) actual = TimeAgo(yearsAgo) assert.Equal(t, "2 years ago", actual) } ================================================ FILE: version/version.go ================================================ package version // Version represents the hub version number var Version = "2.14.2"