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
74 changes: 74 additions & 0 deletions skills/merge-queue/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
---
name: merge-queue
description: >-
Use when you need to add a PR to a GitHub merge queue, check what's currently
queued, or find out why a PR was removed from the queue. The gh CLI has no
built-in merge-queue commands, so this skill provides scripts that use the
GraphQL API.
allowed-tools: Bash(bash skills/merge-queue/scripts/*:*)
Comment thread
ralphbean marked this conversation as resolved.
Comment thread
ralphbean marked this conversation as resolved.
Comment thread
ralphbean marked this conversation as resolved.
---
Comment thread
ralphbean marked this conversation as resolved.

# Merge Queue

## Enqueue a PR

Run `bash skills/merge-queue/scripts/enqueue-pr.sh [PR_NUMBER_OR_URL]` to enqueue a PR.
Omit the argument to enqueue the current branch's PR.

If the PR is not yet eligible (checks pending, missing approvals), use
`await-and-enqueue.sh` instead — see below.

### Accepted input formats

- **PR number:** `652` (uses the current repo context from `gh`)
- **PR URL:** `https://github.com/owner/repo/pull/652`
- **Omitted:** uses the current branch's PR

The `owner/repo#number` format is **not supported** — use a URL or number instead.

## Check queue status

Run `bash skills/merge-queue/scripts/queue-status.sh [OWNER/REPO] [BRANCH]` to list PRs currently in the merge queue.

Both arguments are optional — defaults to the current repo and `main` branch.

Shows each entry's position, state, PR title/URL, author, enqueuer, and estimated time to merge.

## Investigate dequeue reasons

Run `bash skills/merge-queue/scripts/dequeue-reason.sh <PR_NUMBER_OR_URL>` to find out why a PR was removed from the merge queue.

Shows each removal event's timestamp, reason (e.g. `failed_checks`, `merge_conflict`), and the commit SHA at the time of removal.

## Await and enqueue

Run `bash skills/merge-queue/scripts/await-and-enqueue.sh [PR_NUMBER_OR_URL]` to
poll a PR until all required checks pass and the PR is approved, then
automatically enqueue it. Exits early if any check fails.

Use this when `enqueue-pr.sh` rejects a PR because checks are still pending.
GitHub's `auto-merge` API (`gh pr merge --auto`) does not work with merge
queues, so this script fills that gap.

Set `POLL_INTERVAL` (default: 30 seconds) to control how often it checks.

## Prerequisites

- `gh` CLI authenticated with write access to the target repository
- `jq` installed
- The target repository must have merge queues enabled in its branch protection rules

## Constraints

- **Rulesets only:** `await-and-enqueue.sh` discovers required checks from
[repository rulesets](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/about-rulesets)
only. Repositories using classic branch protection rules will not have
their required checks verified before enqueuing.

## Common errors

- **"Pull request is already in the merge queue"** — the PR was previously enqueued; no action needed.
- **"Pull request is not mergeable"** — the PR may need approvals, passing checks, or conflict resolution before it can be enqueued.
- **"Resource not accessible by integration"** — the `gh` token lacks sufficient permissions.
- **"status checks are expected"** — required checks haven't finished yet. Use `await-and-enqueue.sh` to poll and enqueue once they pass.
- **`gh pr merge --auto` fails with merge queues** — GitHub's auto-merge API does not support merge queues. Use `await-and-enqueue.sh` instead.
102 changes: 102 additions & 0 deletions skills/merge-queue/scripts/await-and-enqueue.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
#!/usr/bin/env bash
# Waits for a PR's required checks and approvals, then enqueues it.
# Exits early if any required check fails.
#
# Usage: await-and-enqueue.sh [PR_NUMBER_OR_URL]
#
# If no argument is given, uses the current branch's PR.
# Polls every 30 seconds. Requires: gh CLI, jq.

set -euo pipefail

POLL_INTERVAL="${POLL_INTERVAL:-30}"
pr="${1:-}"

# Resolve PR URL, repo, and base branch
if [[ -z "$pr" ]]; then
pr_json_init="$(gh pr view --json url,baseRefName,headRepository -q '{url,baseRefName,nwo:.headRepository.owner.login+"/"+.headRepository.name}')"
else
pr_json_init="$(gh pr view "$pr" --json url,baseRefName,headRepository -q '{url,baseRefName,nwo:.headRepository.owner.login+"/"+.headRepository.name}')"
fi

pr_url="$(echo "$pr_json_init" | jq -r .url)"
base_branch="$(echo "$pr_json_init" | jq -r .baseRefName)"
repo_nwo="$(echo "$pr_json_init" | jq -r .nwo)"

# Fetch required status checks from branch rulesets (fail-closed on error).
# Note: only the rulesets API is queried. Repositories using classic branch
# protection rules (without rulesets) will not have their required checks
# discovered, and the script will proceed based on reported check statuses only.
if ! required_json="$(gh api "repos/$repo_nwo/rules/branches/$base_branch" \
--jq '[.[] | select(.type == "required_status_checks") | .parameters.required_status_checks[].context] | unique' 2>&1)"; then
echo "Error: failed to fetch required checks for $repo_nwo branch $base_branch" >&2
Comment thread
ralphbean marked this conversation as resolved.
echo "$required_json" >&2
exit 1
fi

if [[ "$(echo "$required_json" | jq 'length')" -gt 0 ]]; then
echo "Required checks: $(echo "$required_json" | jq -r 'join(", ")')"
fi

echo "Waiting for checks and approvals on: $pr_url"

while true; do
# Get check rollup and review decision in one call
pr_json="$(gh pr view "$pr_url" --json statusCheckRollup,reviewDecision)"

review_decision="$(echo "$pr_json" | jq -r '.reviewDecision // "NONE"')"

# Use jq to analyze all check statuses and required check coverage in one pass
result="$(echo "$pr_json" | jq -r --argjson required "$required_json" '
.statusCheckRollup as $checks |
# Build map of check name -> conclusion.
# statusCheckRollup contains both CheckRun (.name, .conclusion) and
# StatusContext (.context, .state) objects — handle both.
($checks | map({((.name // .context // "unknown")): (.conclusion // .state // .status // "PENDING")}) | add // {}) as $map |
# Check for failures (case-insensitive: StatusContext .state may be lowercase)
Comment thread
ralphbean marked this conversation as resolved.
[$map | to_entries[] | select(.value | test("FAILURE|ERROR|CANCELLED|TIMED_OUT|STARTUP_FAILURE|ACTION_REQUIRED|STALE"; "i")) | .key + " (" + .value + ")"] as $failures |
# Check for pending (case-insensitive for the same reason)
[$map | to_entries[] | select(.value | test("SUCCESS|NEUTRAL|SKIPPED|COMPLETED|FAILURE|ERROR|CANCELLED|TIMED_OUT|STARTUP_FAILURE|ACTION_REQUIRED|STALE"; "i") | not) | .key] as $pending |
# Check for missing required checks
[$required[] | select(. as $r | $map | has($r) | not)] as $missing |
{failures: $failures, pending: $pending, missing: $missing}
')"

failures="$(echo "$result" | jq -r '.failures[]' 2>/dev/null || true)"
pending="$(echo "$result" | jq -r '.pending[]' 2>/dev/null || true)"
missing="$(echo "$result" | jq -r '.missing[]' 2>/dev/null || true)"

if [[ -n "$failures" ]]; then
echo "$failures" | while IFS= read -r f; do echo "FAILED: $f"; done
echo "Aborting — one or more required checks failed."
exit 1
fi

has_pending=false
if [[ -n "$pending" ]]; then
has_pending=true
fi
if [[ -n "$missing" ]]; then
echo "$missing" | while IFS= read -r m; do echo "Required check not yet reported: $m"; done
has_pending=true
fi

if [[ "$has_pending" == "true" ]]; then
echo "Waiting ${POLL_INTERVAL}s..."
sleep "$POLL_INTERVAL"
continue
fi

if [[ "$review_decision" != "APPROVED" ]]; then
echo "Checks passed but review not yet approved (status: $review_decision)... waiting ${POLL_INTERVAL}s"
sleep "$POLL_INTERVAL"
continue
fi

echo "All checks passed and PR is approved. Enqueuing..."
break
done

# Delegate to the enqueue script
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec bash "$SCRIPT_DIR/enqueue-pr.sh" "$pr_url"
71 changes: 71 additions & 0 deletions skills/merge-queue/scripts/dequeue-reason.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
#!/usr/bin/env bash
# Shows why a PR was removed from the merge queue.
# Usage: dequeue-reason.sh <PR_NUMBER_OR_URL>
#
# Queries the PR timeline for RemovedFromMergeQueueEvent entries and
# prints the reason, timestamp, and commit SHA for each removal.
# Requires: gh CLI authenticated, jq.

set -euo pipefail

pr="${1:?Usage: dequeue-reason.sh <PR_NUMBER_OR_URL>}"

# Resolve to owner/repo and PR number
if [[ "$pr" =~ ^https://github.com/([^/]+/[^/]+)/pull/([0-9]+) ]]; then
repo="${BASH_REMATCH[1]}"
number="${BASH_REMATCH[2]}"
elif [[ "$pr" =~ ^[0-9]+$ ]]; then
repo="$(gh repo view --json nameWithOwner -q .nameWithOwner)"
number="$pr"
else
echo "Error: provide a PR number or URL" >&2
exit 1
fi

owner="${repo%%/*}"
name="${repo##*/}"

result="$(gh api graphql -f query='
query($owner: String!, $name: String!, $number: Int!) {
repository(owner: $owner, name: $name) {
pullRequest(number: $number) {
title
url
timelineItems(last: 20, itemTypes: [REMOVED_FROM_MERGE_QUEUE_EVENT]) {
nodes {
... on RemovedFromMergeQueueEvent {
createdAt
reason
beforeCommit { abbreviatedOid }
}
}
}
}
}
}
' -f owner="$owner" -f name="$name" -F number="$number")"

# Check for GraphQL errors
if echo "$result" | jq -e '.errors' >/dev/null 2>&1; then
echo "GraphQL errors:" >&2
echo "$result" | jq '.errors' >&2
exit 1
fi

title="$(echo "$result" | jq -r '.data.repository.pullRequest.title')"
url="$(echo "$result" | jq -r '.data.repository.pullRequest.url')"
count="$(echo "$result" | jq '.data.repository.pullRequest.timelineItems.nodes | length')"

if [[ "$count" -eq 0 ]]; then
echo "${url} ${title}"
echo " No merge queue removals found."
exit 0
fi

echo "${url} ${title}"
echo "${count} removal(s):"
echo ""
echo "$result" | jq -r '
.data.repository.pullRequest.timelineItems.nodes[] |
" \(.createdAt) reason: \(.reason) commit: \(.beforeCommit.abbreviatedOid // "unknown")"
'
54 changes: 54 additions & 0 deletions skills/merge-queue/scripts/enqueue-pr.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
# Adds a pull request to a GitHub merge queue using the GraphQL API.
# Usage: enqueue-pr.sh [PR_NUMBER_OR_URL]
#
# If no argument is given, uses the current branch's PR.
# Requires: gh CLI authenticated with sufficient permissions, and jq.

set -euo pipefail
Comment thread
ralphbean marked this conversation as resolved.

pr="${1:-}"

# Resolve PR to its URL and node ID in a single API call
if [[ -z "$pr" ]]; then
pr_json="$(gh pr view --json url,id)"
elif [[ "$pr" =~ ^[0-9]+$ ]]; then
pr_json="$(gh pr view "$pr" --json url,id)"
else
pr_json="$(gh pr view "$pr" --json url,id)"
fi

pr_url="$(echo "$pr_json" | jq -r .url)"
pr_node_id="$(echo "$pr_json" | jq -r .id)"

echo "Enqueuing: $pr_url"

# Enqueue the PR
result="$(gh api graphql -f query='
mutation($prId: ID!) {
enqueuePullRequest(input: {pullRequestId: $prId}) {
mergeQueueEntry {
position
estimatedTimeToMerge
}
}
}
' -f prId="$pr_node_id")"

# Check for GraphQL errors
if echo "$result" | jq -e '.errors' >/dev/null 2>&1; then
echo "GraphQL errors:" >&2
echo "$result" | jq '.errors' >&2
exit 1
fi

position="$(echo "$result" | jq -r '.data.enqueuePullRequest.mergeQueueEntry.position')"

if [[ "$position" == "null" ]]; then
echo "Error: mutation succeeded but mergeQueueEntry is null — the PR may not meet queue requirements." >&2
exit 1
fi

eta="$(echo "$result" | jq -r '.data.enqueuePullRequest.mergeQueueEntry.estimatedTimeToMerge // "unknown"')"
Comment thread
ralphbean marked this conversation as resolved.

echo "PR added to merge queue at position $position (ETA: $eta)"
58 changes: 58 additions & 0 deletions skills/merge-queue/scripts/queue-status.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
#!/usr/bin/env bash
# Lists PRs currently in the merge queue for a branch.
# Usage: queue-status.sh [OWNER/REPO] [BRANCH]
#
# Defaults: OWNER/REPO from current gh repo context, BRANCH=main
# Requires: gh CLI authenticated, jq.

set -euo pipefail

repo="${1:-$(gh repo view --json nameWithOwner -q .nameWithOwner)}"
branch="${2:-main}"
owner="${repo%%/*}"
name="${repo##*/}"

result="$(gh api graphql -f query='
query($owner: String!, $name: String!, $branch: String!) {
repository(owner: $owner, name: $name) {
mergeQueue(branch: $branch) {
entries(first: 50) {
nodes {
position
state
estimatedTimeToMerge
enqueuedAt
enqueuer { login }
pullRequest {
number
title
url
author { login }
}
}
}
}
}
}
' -f owner="$owner" -f name="$name" -f branch="$branch")"

# Check for GraphQL errors
if echo "$result" | jq -e '.errors' >/dev/null 2>&1; then
echo "GraphQL errors:" >&2
echo "$result" | jq '.errors' >&2
exit 1
fi

count="$(echo "$result" | jq '.data.repository.mergeQueue.entries.nodes | length')"

if [[ "$count" -eq 0 ]]; then
echo "Merge queue for ${repo}:${branch} is empty."
exit 0
Comment thread
ralphbean marked this conversation as resolved.
fi

echo "Merge queue for ${repo}:${branch} — ${count} enqueued:"
echo ""
echo "$result" | jq -r '
.data.repository.mergeQueue.entries.nodes[] |
" #\(.position) [\(.state)] \(.pullRequest.url) \(.pullRequest.title)\n by \(.pullRequest.author.login), enqueued \(.enqueuedAt) by \(.enqueuer.login) ETA: \(if .estimatedTimeToMerge then "\(.estimatedTimeToMerge)s" else "unknown" end)"
'
Comment thread
ralphbean marked this conversation as resolved.
Loading