git-open-pr

Open the GitHub pull request page for the current branch in a browser.

#!/usr/bin/env bash
set -euo pipefail

usage() {
  cat <<'EOF'
usage: git open-pr [--print] [--remote <remote>] [--repo <owner/name>] [<fork>/<branch>|<fork>:<branch>]

Open the GitHub pull request in the upstream repository whose head matches the
current branch.

By default, the current branch is interpreted as <fork>/<branch>. For example:

    Takashiidobe/handle-canonicalization-of-bool-cond-cmp

maps to:

    Takashiidobe:handle-canonicalization-of-bool-cond-cmp

Options:
  --print, --no-open       Print the PR URL without opening a browser.
  --remote <remote>        Base repository remote to search (default: upstream,
                           falling back to origin if upstream does not exist).
  --repo <owner/name>      Base GitHub repository to search.
EOF
}

die() {
  printf 'git open-pr: %s\n' "$*" >&2
  exit 1
}

repo_from_github_url() {
  local url=$1
  local repo

  url=${url%/}
  url=${url%.git}

  case "$url" in
    git@github.com:*/*)
      repo=${url#git@github.com:}
      ;;
    ssh://git@github.com/*/*)
      repo=${url#ssh://git@github.com/}
      ;;
    https://github.com/*/*)
      repo=${url#https://github.com/}
      ;;
    http://github.com/*/*)
      repo=${url#http://github.com/}
      ;;
    git://github.com/*/*)
      repo=${url#git://github.com/}
      ;;
    *)
      return 1
      ;;
  esac

  [[ "$repo" == */* ]] || return 1
  printf '%s\n' "$repo"
}

open_url() {
  local url=$1

  if command -v xdg-open >/dev/null 2>&1; then
    xdg-open "$url" >/dev/null 2>&1 &
    return 0
  fi

  if command -v open >/dev/null 2>&1; then
    open "$url" >/dev/null 2>&1 &
    return 0
  fi

  printf 'git open-pr: no browser opener found; printed URL only\n' >&2
}

should_open=true
base_remote=
base_repo=

while (($#)); do
  case "$1" in
    --print|--no-open)
      should_open=false
      shift
      ;;
    --remote)
      (($# >= 2)) || die "--remote requires a value"
      base_remote=$2
      shift 2
      ;;
    --repo)
      (($# >= 2)) || die "--repo requires a value"
      base_repo=$2
      shift 2
      ;;
    --help|-h)
      usage
      exit 0
      ;;
    --)
      shift
      break
      ;;
    -*)
      die "unknown option: $1"
      ;;
    *)
      break
      ;;
  esac
done

(($# <= 1)) || { usage >&2; exit 2; }

git rev-parse --is-inside-work-tree >/dev/null 2>&1 ||
  die "not inside a git worktree"

if (($# == 1)); then
  branch_spec=$1
else
  branch_spec=$(git branch --show-current)
  [[ -n "$branch_spec" ]] || die "cannot infer a branch while HEAD is detached"
fi

if [[ "$branch_spec" == *:* ]]; then
  fork_owner=${branch_spec%%:*}
  head_branch=${branch_spec#*:}
else
  [[ "$branch_spec" == */* ]] ||
    die "expected current branch to look like <fork>/<branch>, got '$branch_spec'"
  fork_owner=${branch_spec%%/*}
  head_branch=${branch_spec#*/}
fi

[[ -n "$fork_owner" ]] || die "empty fork owner in '$branch_spec'"
[[ -n "$head_branch" ]] || die "empty head branch in '$branch_spec'"
[[ "$fork_owner" =~ ^[A-Za-z0-9]([A-Za-z0-9-]{0,37}[A-Za-z0-9])?$ ]] ||
  die "invalid GitHub username: '$fork_owner'"

git check-ref-format --branch "$head_branch" >/dev/null ||
  die "invalid head branch name: '$head_branch'"

if [[ -n "$base_repo" ]]; then
  [[ "$base_repo" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] ||
    die "invalid GitHub repository: '$base_repo'"
else
  if [[ -z "$base_remote" ]]; then
    if git remote get-url upstream >/dev/null 2>&1; then
      base_remote=upstream
    else
      base_remote=origin
    fi
  fi

  remote_url=$(git remote get-url "$base_remote") ||
    die "remote '$base_remote' does not exist"
  base_repo=$(repo_from_github_url "$remote_url") ||
    die "could not infer a GitHub repository from remote '$base_remote': $remote_url"
fi

command -v gh >/dev/null 2>&1 ||
  die "GitHub CLI 'gh' is required"

head_spec="${fork_owner}:${head_branch}"
pr_url=$(
  gh api --method GET \
    -H 'Accept: application/vnd.github+json' \
    "/repos/${base_repo}/pulls" \
    -f state=all \
    -f "head=${head_spec}" \
    --jq 'sort_by(.updated_at) | reverse | .[0].html_url'
)

[[ -n "$pr_url" ]] ||
  die "no PR found in ${base_repo} for head ${head_spec}"

printf '%s\n' "$pr_url"

if [[ "$should_open" == true ]]; then
  open_url "$pr_url"
fi
download git-open-pr