diff --git a/README.md b/README.md index 1b19e4f..9f0b2f3 100644 --- a/README.md +++ b/README.md @@ -139,45 +139,17 @@ installs it. ### `gh-prs-merge` -Walks the same scopes and squash-merges every PR that qualifies, oldest first. -**Dry run by default** — nothing changes until you pass `--apply`. - -```sh -gh-prs-merge --orgs profullstack,moshcoder # report only -gh-prs-merge --orgs profullstack,moshcoder --apply # actually merge -``` - -A PR is merged only when all of these hold: - -- it is open -- it is not a draft, or was successfully marked ready (see below) -- `mergeable` is `MERGEABLE` and `mergeStateStatus` is `CLEAN` -- at least one CI check exists, unless `--allow-no-checks` is passed -- every check is `pass` or `skipping` -- the head commit has not moved between the check and the merge, enforced with - `--match-head-commit` - -Merges never pass `--admin`, so branch protection and required reviews are still -enforced by GitHub. If a merge is refused, that refusal stands. - -**Drafts.** Draft PRs are included by default. Under `--apply` each one is marked -ready for review, re-read, and then judged by the rules above — so a draft with -red CI ends up ready but unmerged, which is usually what you want. A dry run -reports them as `WOULD-READY` and changes nothing. Pass `--no-ready-drafts` to -ignore drafts entirely. - -Options: - -| Flag | Effect | -| --- | --- | -| `--orgs A,B` | search repositories owned by these organizations | -| `--users A,B` | search repositories owned by these personal accounts | -| `--limit N` | maximum PRs per owner, default 1000 | -| `--apply` | actually mark drafts ready and squash-merge | -| `--allow-no-checks` | also merge clean PRs that have no CI checks at all | -| `--no-ready-drafts` | leave draft PRs alone | - -The closing summary counts `ready`, `readied`, `merged`, `skipped`, and `failed`. +Moved to [profullstack/cli-tools](https://github.com/profullstack/cli-tools#gh-prs-merge) +on 2026-09-24, for the same reason `gh-pulse` went: two implementations of one +name on `PATH` drift, and this one had already drifted. The TypeScript copy +carries the fix for GitHub refusing to merge a stacked PR through the GraphQL +mutation, and has tests around it. + +One behaviour differs, so the swap is not silent: repairs were **on** here +unless you passed `--no-fix`, and are **off** there unless you pass `--fix`. +An alias that relied on the old default needs `--fix` added. +`curl -fsSL https://raw.githubusercontent.com/profullstack/cli-tools/master/install.sh | sh` +installs it. ### `domainjson` diff --git a/bin/gh-prs-merge b/bin/gh-prs-merge deleted file mode 100755 index 0379761..0000000 --- a/bin/gh-prs-merge +++ /dev/null @@ -1,674 +0,0 @@ -#!/usr/bin/env bash -set -uo pipefail - -orgs='' -users='' -limit=1000 -apply=0 -allow_no_checks=0 -ready_drafts=1 -fix=auto -fix_wait=600 - -usage() { - cat <<'EOF' -Usage: - gh-prs-merge --orgs ORG1,ORG2 [--users USER1,USER2] [--limit N] [--apply] - gh-prs-merge --users USER1,USER2 [--limit N] [--apply] - -By default, this performs a dry run. - -Options: - --orgs ORG1,ORG2 Search repositories owned by these organizations - --users USER1,USER2 Search repositories owned by these personal accounts - --limit N Maximum PRs per owner; default 1000 - --apply Actually squash-merge eligible PRs - --allow-no-checks Also merge clean PRs that have no CI checks - --no-ready-drafts Ignore draft PRs instead of marking them ready - --no-fix With --apply, repair nothing; judge each PR as it is - --fix-wait SECONDS How long a repair waits on running checks; default 600 - -h, --help Show this help - -Fixing (what --apply does; turn it off with --no-fix): - A skip is not always a verdict on the PR. Some are the tool arriving at the - wrong moment, and re-running by hand is the same work a second time. So a run - that merges repairs each repairable skip once, then judges the PR again - against the identical rules. - - This is part of --apply rather than a flag of its own because --apply already - squash-merges: asking GitHub to update a branch first is not a new kind of - write, and nothing has to opt in to get it. A dry run never repairs, since - every repair writes. --fix is still accepted, and names the default, so an - alias or script that already passes it keeps working. - - Repaired: - checks still running Waits for them to settle, up to --fix-wait, then - re-reads. This is the most common false skip: - mergeStateStatus is UNSTABLE or BLOCKED only - because a check has not reported yet. Nothing is - wrong with the PR and nothing needs changing. - mergeStateStatus=BEHIND - Asks GitHub to merge the base branch in, then waits - for the checks that re-run against the new head. - mergeable=CONFLICTING Same request. It succeeds when the base merely moved - underneath the branch, which GitHub can reconcile on - its own. - - Never repaired: - A conflict GitHub declines to merge is left alone, and the message it gave - is printed. Resolving one means choosing between two authors' intent, and a - batch tool that guesses would produce a merge nobody wrote and nobody - reviewed. The same goes for a check that ran and failed: it is a result, - not an obstacle, and retrying until it passes is how a flaky suite becomes - a green one that means nothing. - -Stacks: - GitHub will not merge a PR that belongs to a stack through the GraphQL - mutation gh uses, and says so rather than merging. Those PRs go to the - asynchronous merge REST endpoint instead, which enqueues the squash and - is polled until it settles. The head commit is still pinned and no admin - override is used, so eligibility is judged exactly as it is for any other - PR. Merge the bottom of a stack first: GitHub retargets what sat on top. - -Drafts: - Draft PRs are included by default. With --apply they are marked ready for - review, then re-evaluated against the eligibility rules below and merged if - they qualify. In a dry run they are reported as WOULD-READY and not changed. - Use --no-ready-drafts to leave drafts alone entirely. - -Eligibility: - - PR is open - - PR is not a draft, or was successfully marked ready - - mergeable is MERGEABLE - - mergeStateStatus is CLEAN - - at least one CI check exists, unless --allow-no-checks is used - - every check is pass or skipping - - head commit has not changed when the merge is submitted -EOF -} - -die() { - printf 'gh-prs-merge: %s\n' "$*" >&2 - exit 2 -} - -while (($#)); do - case "$1" in - --orgs) - (($# >= 2)) || die '--orgs requires a comma-separated value' - orgs=$2 - shift 2 - ;; - - --orgs=*) - orgs=${1#*=} - shift - ;; - - --users) - (($# >= 2)) || die '--users requires a comma-separated value' - users=$2 - shift 2 - ;; - - --users=*) - users=${1#*=} - shift - ;; - - --limit) - (($# >= 2)) || die '--limit requires a number' - limit=$2 - shift 2 - ;; - - --limit=*) - limit=${1#*=} - shift - ;; - - --apply) - apply=1 - shift - ;; - - --allow-no-checks) - allow_no_checks=1 - shift - ;; - - --ready-drafts) - ready_drafts=1 - shift - ;; - - --no-ready-drafts) - ready_drafts=0 - shift - ;; - - --fix) - fix=1 - shift - ;; - - --no-fix) - fix=0 - shift - ;; - - --fix-wait) - (($# >= 2)) || die '--fix-wait requires a number of seconds' - fix_wait=$2 - shift 2 - ;; - - --fix-wait=*) - fix_wait=${1#*=} - shift - ;; - - -h|--help) - usage - exit 0 - ;; - - *) - die "unknown option: $1" - ;; - esac -done - -for dependency in gh jq; do - command -v "$dependency" >/dev/null 2>&1 || - die "required command not found: $dependency" -done - -[[ $limit =~ ^[1-9][0-9]*$ ]] || - die '--limit must be a positive integer' - -((limit <= 1000)) || - die '--limit cannot exceed 1000' - -[[ $fix_wait =~ ^[0-9]+$ ]] || - die '--fix-wait must be a non-negative integer' - -# Unset means: repair when we are merging, never when we are only reporting. -# Resolved here rather than at the declaration because it follows --apply, -# which the caller may pass after --no-fix. -if [[ $fix == auto ]]; then - fix=$apply -fi - -# An explicit --fix in a dry run is a contradiction rather than a default: -# every repair writes, and not writing is the one thing a dry run promises. -if ((fix && apply == 0)); then - die '--fix requires --apply' -fi - -org_list=() -user_list=() - -split_csv() { - local csv=$1 - local -n destination=$2 - local item - local -a raw=() - - [[ -n $csv ]] || return 0 - - IFS=',' read -r -a raw <<< "$csv" - - for item in "${raw[@]}"; do - # Trim surrounding whitespace. - item="${item#"${item%%[![:space:]]*}"}" - item="${item%"${item##*[![:space:]]}"}" - - [[ -n $item ]] && destination+=("$item") - done -} - -split_csv "$orgs" org_list -split_csv "$users" user_list - -if ((${#org_list[@]} == 0 && ${#user_list[@]} == 0)); then - usage >&2 - die 'pass --orgs, --users, or both' -fi - -tmp=$(mktemp) -trap 'rm -f "$tmp"' EXIT - -search_scope() { - local qualifier=$1 - local owner=$2 - local -a draft_filter=() - - # Drafts are pulled in so they can be marked ready; --no-ready-drafts - # restores the old behaviour of never seeing them at all. - ((ready_drafts)) || draft_filter=(--draft=false) - - if ! GH_PAGER=cat gh search prs "${qualifier}:${owner}" \ - --state=open \ - --archived=false \ - "${draft_filter[@]}" \ - --sort=created \ - --order=asc \ - --limit="$limit" \ - --json url,createdAt >> "$tmp" - then - printf 'WARN: skipped inaccessible or invalid scope %s:%s\n' \ - "$qualifier" "$owner" >&2 - fi -} - -# Organizations use org: -for org in "${org_list[@]}"; do - search_scope org "$org" -done - -# Personal repository owners use user: -for user in "${user_list[@]}"; do - search_scope user "$user" -done - -# Combine, deduplicate, and process oldest PRs first. -mapfile -t prs < <( - jq -sr ' - (add // []) - | unique_by(.url) - | sort_by(.createdAt) - | .[].url - ' "$tmp" -) - -if ((${#prs[@]} == 0)); then - if ((ready_drafts)); then - echo 'No open PRs found.' - else - echo 'No open, non-draft PRs found.' - fi - exit 0 -fi - -if ((apply)); then - if ((ready_drafts)); then - echo 'MODE: APPLY — drafts will be marked ready and eligible PRs squash-merged.' - else - echo 'MODE: APPLY — eligible PRs will be squash-merged.' - fi - - ((fix)) && - echo 'MODE: FIX — repairable blockers will be repaired once, then re-judged.' -else - echo 'MODE: DRY RUN — nothing will be merged. Add --apply to merge.' -fi - -merged=0 -ready=0 -readied=0 -skipped=0 -failed=0 -fixed=0 - -# Populates $metadata for a PR, retrying while GitHub is still computing -# state. Pass a second argument to also wait for a just-readied PR to stop -# reporting itself as a draft. -fetch_metadata() { - local pr=$1 - local await_ready=${2:-0} - local attempt - - metadata='' - - for attempt in 1 2 3 4 5; do - if metadata=$( - GH_PAGER=cat gh pr view "$pr" \ - --json state,isDraft,mergeable,mergeStateStatus,headRefOid,title,url \ - 2>/dev/null - ); then - # GitHub may briefly report UNKNOWN while calculating mergeability. - if [[ $(jq -r '.mergeable' <<< "$metadata") != UNKNOWN ]]; then - if ((await_ready == 0)) || - [[ $(jq -r '.isDraft' <<< "$metadata") != true ]] - then - break - fi - fi - fi - - sleep 2 - done -} - -# GitHub refuses the GraphQL merge mutation for a pull request that belongs -# to a stack and points at the asynchronous merge REST endpoint instead. That -# endpoint enqueues the merge and hands back a uuid to poll, so the squash -# still happens under the same rules: the head commit stays pinned and no -# admin override is asked for. -merge_async() { - local pr=$1 - local pinned_sha=$2 - local slug number response status uuid attempt message - - if [[ $pr =~ github\.com/([^/]+/[^/]+)/pull/([0-9]+) ]]; then - slug=${BASH_REMATCH[1]} - number=${BASH_REMATCH[2]} - else - printf 'gh-prs-merge: cannot read owner/repo from %s\n' "$pr" >&2 - return 1 - fi - - if ! response=$( - GH_PAGER=cat gh api \ - --method PUT \ - "repos/$slug/pulls/$number/merge-async" \ - -f merge_method=squash \ - -f "sha=$pinned_sha" \ - 2>&1 - ); then - printf '%s\n' "$response" >&2 - return 1 - fi - - status=$(jq -r '.status // empty' <<< "$response" 2>/dev/null) - uuid=$(jq -r '.details.uuid // empty' <<< "$response" 2>/dev/null) - - # The merge runs in the background, so poll until it settles. A PR handed - # to a merge queue reports enqueued, which is as done as this tool gets. - for ((attempt = 30; attempt > 0; attempt--)); do - case $status in - merged|enqueued) - return 0 - ;; - failed) - message=$( - jq -r '.details.message // "no reason given"' <<< "$response" - ) - printf 'gh-prs-merge: async merge failed — %s\n' "$message" >&2 - return 1 - ;; - esac - - [[ -n $uuid ]] || break - - sleep 2 - - if ! response=$( - GH_PAGER=cat gh api \ - "repos/$slug/pulls/$number/merge-async/$uuid" \ - 2>&1 - ); then - printf '%s\n' "$response" >&2 - return 1 - fi - - status=$(jq -r '.status // empty' <<< "$response" 2>/dev/null) - done - - printf 'gh-prs-merge: async merge never settled (status=%s)\n' \ - "${status:-unknown}" >&2 - return 1 -} - -# Splits $metadata into the fields the eligibility rules read. -parse_metadata() { - state=$(jq -r '.state' <<< "$metadata") - draft=$(jq -r '.isDraft' <<< "$metadata") - mergeable=$(jq -r '.mergeable' <<< "$metadata") - merge_state=$(jq -r '.mergeStateStatus' <<< "$metadata") - head_sha=$(jq -r '.headRefOid' <<< "$metadata") - title=$( - jq -r '.title | gsub("[\r\n\t]+"; " ")' <<< "$metadata" - ) -} - -# Populates the check tallies the eligibility rules read. -# -# `gh pr checks` exits nonzero when anything is pending or failing while still -# printing valid JSON, so the buckets are evaluated directly rather than the -# exit code. -collect_checks() { - local pr=$1 - local checks_output='' - - checks='[]' - - checks_output=$( - GH_PAGER=cat gh pr checks "$pr" \ - --json bucket,name 2>/dev/null - ) || true - - if [[ -n $checks_output ]] && - jq -e 'type == "array"' >/dev/null 2>&1 <<< "$checks_output" - then - checks=$checks_output - fi - - check_count=$(jq 'length' <<< "$checks") - - bad_checks=$( - jq ' - [ - .[] - | select( - .bucket != "pass" and - .bucket != "skipping" - ) - ] - | length - ' <<< "$checks" - ) - - # Tracked apart from bad_checks because the two want opposite treatment: a - # pending check is a reason to wait, a failing one is a reason to stop. - pending_checks=$( - jq '[.[] | select(.bucket == "pending")] | length' <<< "$checks" - ) - - bad_check_names=$( - jq -r ' - [ - .[] - | select( - .bucket != "pass" and - .bucket != "skipping" - ) - | "\(.name)=\(.bucket)" - ] - | join(", ") - ' <<< "$checks" - ) -} - -# The eligibility rules, in one place so --fix can re-run them unchanged. -compute_reason() { - reason='' - - if [[ $state != OPEN ]]; then - reason="state=$state" - elif [[ $draft == true ]]; then - reason='draft' - elif [[ $mergeable != MERGEABLE ]]; then - reason="mergeable=$mergeable" - elif [[ $merge_state != CLEAN ]]; then - reason="mergeStateStatus=$merge_state" - elif ((check_count == 0 && allow_no_checks == 0)); then - reason='no CI checks found' - elif ((bad_checks > 0)); then - reason="checks not green: $bad_check_names" - fi -} - -# Blocks while any check is still running, up to --fix-wait. -# -# Returns 0 if the checks finished within the budget, 1 on timeout. Either way -# the caller re-reads state; a timeout is not a failure of the PR, only of our -# patience, and it should still be reported with whatever the real reason is. -wait_for_checks() { - local pr=$1 - local waited=0 - - while ((pending_checks > 0 && waited < fix_wait)); do - printf ' … %s check(s) still running on %s; waiting\n' \ - "$pending_checks" "$pr" - sleep 20 - waited=$((waited + 20)) - collect_checks "$pr" - done - - ((pending_checks == 0)) -} - -# Attempts one repair of a repairable skip. -# -# Returns 0 when something was done and the PR deserves a second look, 1 when -# the reason is not one this should touch. -attempt_fix() { - local pr=$1 - local update_output='' - - # Checks still running. The PR is not blocked, it is unfinished, and the - # only defect is that we looked too early. - if ((pending_checks > 0)); then - printf 'FIXING %s — checks still running; waiting up to %ss\n' \ - "$pr" "$fix_wait" - wait_for_checks "$pr" - return 0 - fi - - # Base branch moved. GitHub can merge it in without a local checkout, and - # does so only when the result needs no human judgement. - if [[ $merge_state == BEHIND || - $merge_state == DIRTY || - $mergeable == CONFLICTING ]] - then - printf 'FIXING %s — %s; asking GitHub to merge the base branch in\n' \ - "$pr" "$reason" - - if update_output=$( - GH_PAGER=cat gh pr update-branch "$pr" 2>&1 - ); then - # New head, so every check re-runs. Waiting here is what makes the fix - # worth anything — otherwise the re-check sees a pending suite and skips - # for the very reason we just set in motion. - collect_checks "$pr" - wait_for_checks "$pr" - return 0 - fi - - # A real conflict. Print what GitHub said and leave it: choosing between - # two authors' intent is not a batch operation. - printf 'FIXME %s — GitHub could not merge the base in: %s\n' \ - "$pr" "$(tr '\n' ' ' <<< "$update_output")" >&2 - return 1 - fi - - return 1 -} - -for pr in "${prs[@]}"; do - fetch_metadata "$pr" - - if [[ -z $metadata ]] || - ! jq -e 'type == "object"' >/dev/null 2>&1 <<< "$metadata" - then - printf 'SKIP %s — could not read PR metadata\n' "$pr" - ((skipped += 1)) - continue - fi - - parse_metadata - - # Take drafts out of draft first, then judge them like any other PR. - if [[ $draft == true && $state == OPEN ]] && ((ready_drafts)); then - if ((apply == 0)); then - printf 'WOULD-READY %s — draft; would mark ready, then re-check — %s\n' \ - "$pr" "$title" - ((skipped += 1)) - continue - fi - - if GH_PAGER=cat gh pr ready "$pr"; then - printf 'READIED %s — %s\n' "$pr" "$title" - ((readied += 1)) - - fetch_metadata "$pr" 1 - - if [[ -z $metadata ]] || - ! jq -e 'type == "object"' >/dev/null 2>&1 <<< "$metadata" - then - printf 'SKIP %s — could not re-read PR metadata after marking ready\n' \ - "$pr" - ((skipped += 1)) - continue - fi - - parse_metadata - else - printf 'FAILED %s — could not mark draft ready\n' "$pr" >&2 - ((failed += 1)) - continue - fi - fi - - collect_checks "$pr" - compute_reason - - # One repair attempt, then the same rules decide again. Deliberately not a - # loop: if a repair did not make the PR mergeable, repeating it will not - # either, and a tool that keeps trying is how a batch run turns into an - # afternoon of API calls. - if [[ -n $reason ]] && ((fix)); then - if attempt_fix "$pr"; then - ((fixed += 1)) - fetch_metadata "$pr" - - if [[ -n $metadata ]] && - jq -e 'type == "object"' >/dev/null 2>&1 <<< "$metadata" - then - parse_metadata - collect_checks "$pr" - compute_reason - fi - fi - fi - - if [[ -n $reason ]]; then - printf 'SKIP %s — %s — %s\n' "$pr" "$reason" "$title" - ((skipped += 1)) - continue - fi - - printf 'READY %s — %s checks green — %s\n' \ - "$pr" "$check_count" "$title" - - ((ready += 1)) - - if ((apply)); then - # Deliberately no --admin: GitHub rules and protections remain enforced. - if merge_output=$( - GH_PAGER=cat gh pr merge "$pr" \ - --squash \ - --match-head-commit "$head_sha" \ - 2>&1 - ); then - printf '%s\n' "$merge_output" - printf 'MERGED %s\n' "$pr" - ((merged += 1)) - elif [[ $merge_output == *"asynchronous merge REST API"* ]] && - merge_async "$pr" "$head_sha" - then - # A stacked PR: the mutation is closed to it, the REST endpoint is not. - printf 'MERGED %s — via the asynchronous merge API (stacked)\n' "$pr" - ((merged += 1)) - else - printf '%s\n' "$merge_output" >&2 - printf 'FAILED %s — GitHub refused the merge\n' "$pr" >&2 - ((failed += 1)) - fi - fi -done - -printf '\nSummary: ready=%d readied=%d fixed=%d merged=%d skipped=%d failed=%d\n' \ - "$ready" "$readied" "$fixed" "$merged" "$skipped" "$failed"