From 7e9550ed22af3cafb3874976bceeb29acdb0abd1 Mon Sep 17 00:00:00 2001 From: McAmner Date: Thu, 2 Jul 2026 23:29:29 +0200 Subject: [PATCH] feat(git): add remote PR-merge helper and wire it into the git menus gitmerge-safe.sh only does local merges and never pushes, so closing a GitHub pull request still fell back to raw gh commands. Add gitpr-merge-safe.sh as its remote counterpart: it resolves the target PR (arg, current branch, or picker), shows a merge plan with checks/mergeable/review state, soft-blocks on unclean PRs, merges via gh pr merge (squash + --delete-branch by default), then syncs the local base branch. Same guardrail style as gitmerge-safe.sh (TTY-gated, plan then confirm). Wire it in as 'p. PR merge' in gitlaunch (alongside 'm. Safe merge') and 'p. Merge pull request' in the MQ Git menu, both delegating to the shared helper. Co-Authored-By: Claude Opus 4.8 --- terminal/launchers/gitlaunch.sh | 35 +++- terminal/launchers/gitpr-merge-safe.sh | 215 +++++++++++++++++++++++++ terminal/menus/mq-git-menu.sh | 29 ++++ 3 files changed, 277 insertions(+), 2 deletions(-) create mode 100755 terminal/launchers/gitpr-merge-safe.sh diff --git a/terminal/launchers/gitlaunch.sh b/terminal/launchers/gitlaunch.sh index efe8623..fbc6084 100755 --- a/terminal/launchers/gitlaunch.sh +++ b/terminal/launchers/gitlaunch.sh @@ -522,7 +522,7 @@ function render_menu() { frame_two_col "5. Open repo" "6. Dev mode" frame_two_col "7. Switch repo" "8. Auto commit + push" frame_two_col "9. Recent log" "m. Safe merge" - frame_two_col "b. Back" "" + frame_two_col "p. PR merge" "b. Back" frame_bottom } @@ -551,7 +551,7 @@ function prompt_choice() { printf "\n%b%s%b\n" "$C_BORDER" "$sep" "$C_RESET" printf "%bgitlaunch > %b\n" "$C_TITLE" "$C_RESET" printf "%b%s%b\n" "$C_BORDER" "$sep" "$C_RESET" - printf "%b>> press 1-9, m or b%b\n" "$C_DIM" "$C_RESET" + printf "%b>> press 1-9, m, p or b%b\n" "$C_DIM" "$C_RESET" if [[ -t 0 && -t 1 ]]; then printf "\033[3A\r" printf "%bgitlaunch > %b" "$C_TITLE" "$C_RESET" @@ -868,6 +868,33 @@ function safe_merge() { ( cd "${WORK_DIR:-$REPO}" && "$merge_script" ) } +# ------------------------ +# PR MERGE (REMOTE) +# ------------------------ +# Runs the remote PR-merge helper against the active repo. Unlike safe_merge +# (local merge, no push), this closes a GitHub pull request via gh pr merge. +# Resolution order: explicit override, repo copy next to this launcher, then the +# legacy ~/mqlaunch/scripts location. +function pr_merge() { + local merge_script="${MQ_GITPR_MERGE_SCRIPT:-}" + + if [[ -z "$merge_script" ]]; then + if [[ -x "$GITLAUNCH_DIR/gitpr-merge-safe.sh" ]]; then + merge_script="$GITLAUNCH_DIR/gitpr-merge-safe.sh" + else + merge_script="$HOME/mqlaunch/scripts/gitpr-merge-safe.sh" + fi + fi + + if [[ ! -x "$merge_script" ]]; then + echo "PR-merge script not found or not executable:" + echo " $merge_script" + return 1 + fi + + ( cd "${WORK_DIR:-$REPO}" && "$merge_script" ) +} + # ------------------------ # COMMIT SUGGESTION # ------------------------ @@ -1017,6 +1044,10 @@ while true; do safe_merge pause_git_menu ;; + p|P) + pr_merge + pause_git_menu + ;; b|B) mark_gitlaunch_back break diff --git a/terminal/launchers/gitpr-merge-safe.sh b/terminal/launchers/gitpr-merge-safe.sh new file mode 100755 index 0000000..b2ef7ac --- /dev/null +++ b/terminal/launchers/gitpr-merge-safe.sh @@ -0,0 +1,215 @@ +#!/usr/bin/env bash +set -euo pipefail +IFS=$'\n\t' + +# Remote PR-merge companion to gitmerge-safe.sh. +# +# gitmerge-safe.sh does LOCAL merges (source branch -> current branch) and never +# pushes. This script closes a REMOTE pull request via `gh pr merge`, with the +# same guardrails: show the plan, refuse without a TTY, confirm before acting. + +# Coordinates red behavior. +red() { printf '\033[31m%s\033[0m\n' "$*"; } +# Coordinates green behavior. +green() { printf '\033[32m%s\033[0m\n' "$*"; } +# Coordinates yellow behavior. +yellow() { printf '\033[33m%s\033[0m\n' "$*"; } +# Coordinates blue behavior. +blue() { printf '\033[34m%s\033[0m\n' "$*"; } + +# Coordinates die behavior. +die() { + red "Error: $*" + exit 1 +} + +# Verifies the required cmd helper is available before continuing. +require_cmd() { + command -v "$1" >/dev/null 2>&1 || die "Missing command: $1" +} + +require_cmd git +require_cmd gh + +git rev-parse --is-inside-work-tree >/dev/null 2>&1 || die "Not inside a Git repository." + +# Class C interactive action: refuse before any network operation if there is no +# TTY to confirm against. +[[ -t 0 ]] || die "This script needs an interactive terminal (stdin is not a TTY)." + +gh auth status >/dev/null 2>&1 || die "GitHub CLI is not authenticated. Run: gh auth login" + +repo_root="$(git rev-parse --show-toplevel)" +cd "$repo_root" + +current_branch="$(git rev-parse --abbrev-ref HEAD)" +[[ "$current_branch" != "HEAD" ]] || die "Detached HEAD is not supported. Checkout a branch first." + +blue "Repo: $(basename "$repo_root")" +blue "Current branch: $current_branch" +echo + +# ------------------------ +# RESOLVE TARGET PR +# ------------------------ +# Priority: explicit argument -> PR for the current branch -> pick from open list. +pr_number="${1:-}" + +if [[ -z "$pr_number" ]]; then + # Try the PR whose head is the current branch. + pr_number="$(gh pr view --json number --jq '.number' 2>/dev/null || true)" +fi + +if [[ -z "$pr_number" ]]; then + yellow "No PR argument and no PR for the current branch. Open pull requests:" + echo + gh pr list --state open --limit 30 \ + --json number,title,headRefName,baseRefName \ + --template '{{range .}}{{printf " %v) #%v %s (%s -> %s)\n" .number .number .title .headRefName .baseRefName}}{{end}}' \ + || die "Could not list pull requests." + echo + read -r -p "PR number to merge (or q to quit): " pr_number + [[ "${pr_number:-}" =~ ^[Qq]$ ]] && exit 0 +fi + +[[ "${pr_number:-}" =~ ^[0-9]+$ ]] || die "Invalid PR number: ${pr_number:-}" + +# ------------------------ +# SHOW MERGE PLAN +# ------------------------ +gh pr view "$pr_number" --json number >/dev/null 2>&1 || die "Could not read PR #$pr_number." + +# Read one field at a time via gh's built-in --jq, so we do not depend on a +# standalone jq binary being installed. +pr_field() { + gh pr view "$pr_number" --json "$1" --jq ".$1" 2>/dev/null || true +} + +title="$(pr_field title)" +state="$(pr_field state)" +is_draft="$(pr_field isDraft)" +base_ref="$(pr_field baseRefName)" +head_ref="$(pr_field headRefName)" +mergeable="$(pr_field mergeable)" +merge_state="$(pr_field mergeStateStatus)" +review_decision="$(pr_field reviewDecision)" +pr_url="$(pr_field url)" + +echo +blue "Merge plan" +echo " PR: #$pr_number $title" +echo " Flow: $head_ref -> $base_ref" +echo " State: ${state:-unknown}${is_draft:+ (draft: $is_draft)}" +echo " Mergeable:${mergeable:+ $mergeable}${merge_state:+ / $merge_state}" +echo " Review: ${review_decision:-none}" +echo " URL: ${pr_url:-n/a}" +echo + +if [[ "$state" != "OPEN" ]]; then + die "PR #$pr_number is not open (state: ${state:-unknown})." +fi + +if [[ "$is_draft" == "true" ]]; then + die "PR #$pr_number is a draft. Mark it ready for review first." +fi + +# CI / check summary. +yellow "Checks:" +gh pr checks "$pr_number" 2>/dev/null || yellow " (no checks reported)" +echo + +# ------------------------ +# SOFT-BLOCK ON RISK +# ------------------------ +risky=0 +if [[ "$mergeable" == "CONFLICTING" ]]; then + red "PR is CONFLICTING. Resolve conflicts before merging." + risky=1 +fi +if [[ "$merge_state" == "BLOCKED" || "$merge_state" == "BEHIND" || "$merge_state" == "DIRTY" ]]; then + yellow "Merge state is $merge_state (branch protection or out-of-date branch)." + risky=1 +fi +if [[ "$review_decision" == "CHANGES_REQUESTED" ]]; then + yellow "Review decision is CHANGES_REQUESTED." + risky=1 +fi +if ! gh pr checks "$pr_number" >/dev/null 2>&1; then + yellow "One or more checks are failing or pending." + risky=1 +fi + +if [[ "$risky" -eq 1 ]]; then + echo + read -r -p "This PR is not cleanly mergeable. Proceed anyway? [y/N]: " force + [[ "${force:-}" =~ ^[Yy]$ ]] || { yellow "Merge cancelled."; exit 0; } +fi + +# ------------------------ +# MERGE METHOD +# ------------------------ +method="${2:-}" +if [[ -z "$method" ]]; then + echo + echo "Merge method:" + echo " 1) squash (default)" + echo " 2) merge commit" + echo " 3) rebase" + read -r -p "Choice [1-3, Enter=1]: " m + case "${m:-1}" in + 1|"") method="squash" ;; + 2) method="merge" ;; + 3) method="rebase" ;; + *) die "Invalid method choice." ;; + esac +fi + +case "$method" in + squash) method_flag="--squash" ;; + merge) method_flag="--merge" ;; + rebase) method_flag="--rebase" ;; + *) die "Unknown merge method: $method" ;; +esac + +# ------------------------ +# CONFIRM + MERGE +# ------------------------ +echo +blue "About to run:" +echo " gh pr merge $pr_number $method_flag --delete-branch" +echo +read -r -p "Proceed with remote PR merge? [y/N]: " confirm +[[ "${confirm:-}" =~ ^[Yy]$ ]] || { yellow "Merge cancelled."; exit 0; } + +echo +if gh pr merge "$pr_number" "$method_flag" --delete-branch; then + green "PR #$pr_number merged ($method) and remote branch deleted." +else + die "gh pr merge failed for PR #$pr_number." +fi + +# ------------------------ +# SYNC LOCAL BASE +# ------------------------ +echo +if [[ -n "$base_ref" ]]; then + yellow "Syncing local $base_ref..." + if git switch "$base_ref" 2>/dev/null || git checkout "$base_ref" 2>/dev/null; then + git pull --ff-only origin "$base_ref" || yellow "Could not fast-forward $base_ref (resolve manually)." + else + yellow "Could not switch to $base_ref locally (skipping sync)." + fi + + # Clean up the now-merged local head branch if it still exists and is safe. + if [[ "$head_ref" != "$base_ref" ]] && git show-ref --verify --quiet "refs/heads/$head_ref"; then + if git branch -d "$head_ref" 2>/dev/null; then + green "Deleted local branch $head_ref." + else + yellow "Local branch $head_ref not deleted (not fully merged locally); remove manually if desired." + fi + fi +fi + +echo +green "Done." +git --no-pager log --oneline --decorate -n 6 diff --git a/terminal/menus/mq-git-menu.sh b/terminal/menus/mq-git-menu.sh index 5ed5da2..04c602c 100755 --- a/terminal/menus/mq-git-menu.sh +++ b/terminal/menus/mq-git-menu.sh @@ -544,6 +544,33 @@ pull_rebase() { pause_enter } +# Merges a remote pull request via gh pr merge. +# Unlike a local merge, this closes the PR on GitHub (squash + delete branch by +# default) and syncs the local base branch afterwards. Delegates to the shared +# gitpr-merge-safe.sh helper so guardrails stay in one place. +merge_pull_request() { + ensure_repo || return 1 + + local merge_script="${MQ_GITPR_MERGE_SCRIPT:-}" + if [[ -z "$merge_script" ]]; then + if [[ -x "$BASE_DIR/terminal/launchers/gitpr-merge-safe.sh" ]]; then + merge_script="$BASE_DIR/terminal/launchers/gitpr-merge-safe.sh" + else + merge_script="$HOME/mqlaunch/scripts/gitpr-merge-safe.sh" + fi + fi + + if [[ ! -x "$merge_script" ]]; then + ui_err "PR-merge script not found or not executable: $merge_script" + pause_enter + return 1 + fi + + ( cd "$CURRENT_REPO" && "$merge_script" ) + echo + pause_enter +} + # Shows log. show_log() { ensure_repo || return 1 @@ -610,6 +637,7 @@ print_git_menu() { surface_split_row "3. Suggest commit message" "4. Next recommended action" "$width" "$panel_color" surface_split_row "5. Stage selected files" "6. Commit staged changes" "$width" "$panel_color" surface_split_row "7. Safe push" "8. Pull with rebase" "$width" "$panel_color" + surface_split_row "p. Merge pull request" "" "$width" "$panel_color" surface_row "" "$width" "$panel_color" surface_row "NAVIGATION" "$width" "$panel_color" surface_split_row "10. Open repo on GitHub" "11. Open local repo folder" "$width" "$panel_color" @@ -639,6 +667,7 @@ git_menu_loop() { 6) commit_changes ;; 7) safe_push ;; 8) pull_rebase ;; + p|P) merge_pull_request ;; 9) show_log ;; 10) open_repo_github ;; 11) open_local_repo ;;