-
Notifications
You must be signed in to change notification settings - Fork 15
feat(skills): add merge-queue skill #94
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
84c2058
feat(skills): add merge-queue skill from fullsend
ralphbean 459235d
fix(merge-queue): fail-closed on API errors, add GraphQL error checks
ralphbean 90e2a01
fix(merge-queue): address review feedback on PR #94
fullsend-ai-coder[bot] 391f6c2
fix(merge-queue): handle STALE checks and null enqueue position
ralphbean File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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/*:*) | ||
|
ralphbean marked this conversation as resolved.
ralphbean marked this conversation as resolved.
|
||
| --- | ||
|
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. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
|
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) | ||
|
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" | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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")" | ||
| ' |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
|
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"')" | ||
|
ralphbean marked this conversation as resolved.
|
||
|
|
||
| echo "PR added to merge queue at position $position (ETA: $eta)" | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
|
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)" | ||
| ' | ||
|
ralphbean marked this conversation as resolved.
|
||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.