Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 33 additions & 2 deletions terminal/launchers/gitlaunch.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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
# ------------------------
Expand Down Expand Up @@ -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
Expand Down
215 changes: 215 additions & 0 deletions terminal/launchers/gitpr-merge-safe.sh
Original file line number Diff line number Diff line change
@@ -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:-<empty>}"

# ------------------------
# 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
29 changes: 29 additions & 0 deletions terminal/menus/mq-git-menu.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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 ;;
Expand Down
Loading