This is an automated email from the ASF dual-hosted git repository.
potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow-steward.git
The following commit(s) were added to refs/heads/main by this push:
new d76f80b fix(pr-management-quick-merge): resolve mergeability live,
treat BLOCKED as approval-needed (#432)
d76f80b is described below
commit d76f80b56f7de75f92786b754aa73c3996fab02b
Author: Jarek Potiuk <[email protected]>
AuthorDate: Mon Jun 1 19:03:15 2026 +0200
fix(pr-management-quick-merge): resolve mergeability live, treat BLOCKED as
approval-needed (#432)
Real-queue dry-run lesson: GitHub's batched mergeable/mergeStateStatus is
unreliable at queue scale — most ready PRs report BLOCKED/UNKNOWN in a batch
(branch protection withholding the merge pending the required approval), so
a
batch mergeability gate dropped 177 of 204 ready PRs, almost none actually
conflicting.
Changes:
- Stage 1 no longer gates on batch mergeStateStatus; it only early-drops
obviously batch-CONFLICTING PRs.
- New Stage 3 resolves mergeability live per surviving candidate
(GET /pulls/<N> forces GitHub to compute it), one cheap REST call each.
- mergeStateStatus=blocked is recognised as 'needs your approval' (clean
branch, missing committer approval) and routed to the [A]pprove-then-merge
path — the skill's primary case — not a drop.
- Presentation splits into two buckets: ready-to-merge and needs-approval.
- Drop taxonomy gains gate:G5-conflict and gate:G5-unknown.
---
.claude/skills/pr-management-quick-merge/SKILL.md | 84 ++++++++++++--------
.../pr-management-quick-merge/candidate-rules.md | 92 ++++++++++++++++++----
2 files changed, 126 insertions(+), 50 deletions(-)
diff --git a/.claude/skills/pr-management-quick-merge/SKILL.md
b/.claude/skills/pr-management-quick-merge/SKILL.md
index 7d9870b..9075502 100644
--- a/.claude/skills/pr-management-quick-merge/SKILL.md
+++ b/.claude/skills/pr-management-quick-merge/SKILL.md
@@ -131,14 +131,19 @@ already performs on confirmation), not Mode D. The
approve is gated by
and detailed in [Step 3b](#step-3b--optional-approve-action). Everything else
the skill emits is read-only.
-**Golden rule 2 — all gates green is non-negotiable.** A PR reaches the
-triviality screen only after it passes **every** quality gate: real CI green
-(rollup SUCCESS *and* the [Real-CI
guard](../pr-management-triage/classify-and-act.md#real-ci-guard)
-confirms real CI actually ran, not just `Mergeable`/`DCO`/`boring-cyborg`),
-`mergeable != CONFLICTING`, GitHub `mergeStateStatus` not `BLOCKED`/`DIRTY`, no
+**Golden rule 2 — all gates green is non-negotiable; mergeability is resolved
+live.** A PR reaches the triviality screen only after it passes **every**
+quality gate: real CI green (rollup SUCCESS *and* the [Real-CI
guard](../pr-management-triage/classify-and-act.md#real-ci-guard)
+confirms real CI actually ran, not just `Mergeable`/`DCO`/`boring-cyborg`), no
unresolved collaborator review threads, no outstanding `CHANGES_REQUESTED`, and
-no workflow run in `action_required`. A near-miss is **not** surfaced — there
-is no "almost green" tier. The gate is in
[`candidate-rules.md`](candidate-rules.md#stage-1--quality-gate).
+no workflow run in `action_required`. A near-miss is **not** surfaced — there
is
+no "almost green" tier. **Mergeability is deliberately *not* gated from the
+batch** — GitHub reports `BLOCKED`/`UNKNOWN` for most ready PRs in a batched
+fetch (branch protection withholding the merge pending an approval), so gating
+on it drops nearly the whole queue. Instead it is resolved by a **live
+per-candidate re-poll** in [Stage
3](candidate-rules.md#stage-3--live-merge-readiness),
+where `BLOCKED` is recognised as *"needs your approval"* (the skill's primary
+case), not a conflict. The gates are in
[`candidate-rules.md`](candidate-rules.md#stage-1--quality-gate).
**Golden rule 3 — allow-list wins, one consequential file disqualifies.** A PR
is trivial only if **every** changed file matches the supplementary allow-list
@@ -253,52 +258,65 @@ maintainer asks for `[V]iew diff` on a specific candidate.
---
-## Step 2 — Two-stage screen
+## Step 2 — Three-stage screen
Run every fetched PR through [`candidate-rules.md`](candidate-rules.md):
-1. **Quality-gate gate** (hard pass/fail) — drop any PR that is not green on
- every gate in Golden rule 2. No partial credit.
-2. **Triviality classification** — of the survivors, keep those whose footprint
- is within `max_churn` / `max_files` **and** whose every file matches the
- allow-list with none in the deny-list. Assign Tier A or Tier B.
-
-The screen is a pure function of the data fetched in Step 1 (plus the lazy
diff,
-which is not needed for the screen itself). No mutations, no prompts.
-
-The output is a list of `(pr, tier, churn, files, gate_evidence)` tuples.
+1. **Quality-gate gate** (hard pass/fail, from the batch) — drop any PR not
green
+ on every Stage-1 gate: real CI green, no failed/pending checks, no workflow
+ approval pending, no unresolved collaborator threads, no outstanding
+ changes-requested. Mergeability is **not** gated here beyond an early-drop
of
+ the obviously batch-`CONFLICTING`. No partial credit.
+2. **Triviality classification** (from the batch) — of the survivors, keep
those
+ whose footprint is within `max_churn` / `max_files` **and** whose every file
+ matches the allow-list with none in the deny-list. Assign Tier A or Tier B.
+3. **Live merge-readiness** — for each survivor (now a handful), make **one
REST
+ call** (`GET /repos/<repo>/pulls/<N>`) to resolve `mergeable` +
+ `mergeable_state` live, because the batched value is unreliable for a large
+ `ready` queue. Bucket each as **ready-to-merge**
(`clean`/`unstable`/`behind`),
+ **needs-approval** (`blocked` — branch merges cleanly but a committer
approval
+ is missing; the skill's primary case), or **drop** (`dirty`/conflict, or
still
+ `unknown` this run). See [Stage
3](candidate-rules.md#stage-3--live-merge-readiness).
+
+Stages 1–2 are a pure function of the Step-1 batch (no mutations, no prompts);
+Stage 3 adds the small per-candidate re-poll. The output is two ranked lists:
+**ready-to-merge** and **needs-approval-then-merge**.
---
## Step 3 — Rank and present
-Order: **Tier A before Tier B; within a tier, smallest churn first; ties broken
-by oldest-updated.** Present as a single read-only group:
+Order within each bucket: **Tier A before Tier B; within a tier, smallest churn
+first; ties broken by oldest-updated.** Present **two** read-only buckets — the
+*ready-to-merge* set first, then the *needs-your-approval-then-merge* set:
```text
─────────────────────────────────────────────────────
-Quick-merge candidates — N PRs · all gates green · review & merge yourself
+Quick-merge candidates · all gates green · review & act yourself
─────────────────────────────────────────────────────
- [A] #67676 @vividbaek +18/-0 1 file Tier A (docs)
- docs/apache-airflow/howto/remediation.rst
- gates: CI ✓ (Tests, Static checks, Docs) mergeable ✓ threads 0
approvals: 0
- merge: gh pr merge 67676 --squash --repo <repo>
+READY TO MERGE — M PRs (clean / mergeable now)
+ [A] #67452 @nailo2c +12/-1 1 file Tier A (docs) mergeable_state:
clean
+ airflow-core/docs/core-concepts/dags.rst
+ gates: CI ✓ (Tests, Static checks, Docs) threads 0 approvals: 1
+ merge: gh pr merge 67452 --squash --repo <repo>
[V] view full diff
- [A] #67685 @ed-kyu +2/-3 1 file Tier A (comment/typo)
- providers/http/.../http.py (TODO comment removal — within churn
budget, path not in deny-list)
- gates: CI ✓ mergeable ✓ threads 0 approvals: 1
- merge: gh pr merge 67685 --squash --repo <repo>
+NEEDS YOUR APPROVAL, THEN MERGE — K PRs (clean branch, blocked on a missing
committer approval)
+ [A] #64724 @auyua9 +2/-2 1 file Tier A (docs) mergeable_state:
blocked (REVIEW_REQUIRED)
+ INSTALLING.md
+ gates: CI ✓ threads 0 approvals: 0
+ action: [A]pprove 64724 → then gh pr merge 64724 --squash --repo
<repo>
[V] view full diff
...
```
For each candidate print: PR number (clickable), author, `+adds/-dels`, file
-count, tier + one-word reason, the **full file list**, an explicit per-gate
-attestation (which real-CI checks are green, mergeable state, unresolved-thread
-count, current approval count), the exact **merge command** from
-`merge_command_template`, and a `[V]iew diff` affordance.
+count, tier + one-word reason, the live `mergeable_state`, the **full file
+list**, an explicit per-gate attestation (which real-CI checks are green,
+unresolved-thread count, current approval count), and a `[V]iew diff`
affordance.
+For the *ready* bucket print the **merge command**; for the *needs-approval*
+bucket print the `[A]pprove NN` → merge sequence.
The maintainer's options on the group:
diff --git a/.claude/skills/pr-management-quick-merge/candidate-rules.md
b/.claude/skills/pr-management-quick-merge/candidate-rules.md
index fd2a5d9..390e2a1 100644
--- a/.claude/skills/pr-management-quick-merge/candidate-rules.md
+++ b/.claude/skills/pr-management-quick-merge/candidate-rules.md
@@ -40,16 +40,25 @@ least as clean as a PR the triage skill would call
`passing`.
| G2 | Real CI green | `statusCheckRollup.state == SUCCESS` **and** the
[Real-CI guard](../pr-management-triage/classify-and-act.md#real-ci-guard)
passes — at least one context matches a `real_ci_patterns` entry, so the
SUCCESS is not coming only from `Mergeable`/`WIP`/`DCO`/`boring-cyborg`. |
| G3 | No failed/pending checks | `failed_checks` is empty **and** no context
is still `QUEUED`/`IN_PROGRESS`/`PENDING`. A candidate must be *done and
green*, not green-so-far. |
| G4 | No workflow approval pending | the PR's `head_sha` is **not** in the
per-session `action_required` index. |
-| G5 | Mergeable | `mergeable == MERGEABLE` (not `CONFLICTING`, not `UNKNOWN`)
**and** `mergeStateStatus ∉ {DIRTY, BLOCKED, UNKNOWN}`. `BEHIND` is allowed (a
behind-but-clean branch still merges); `UNSTABLE` is allowed only if G2/G3
already proved every *required* check green (UNSTABLE can come from a
non-required check). |
+| G5 | Not obviously conflicting | **Mergeability is resolved live in [Stage
3](#stage-3--live-merge-readiness), not from the batch.** Stage 1 only
early-drops a PR whose *batch* `mergeable == CONFLICTING` (a cheap cull of the
obviously-conflicted ~10%). `MERGEABLE` and `UNKNOWN` both pass G5 here and
defer to the Stage 3 re-poll — see the note below for why. |
| G6 | No unresolved collaborator threads | zero `reviewThreads` with
`isResolved == false` whose first comment's `authorAssociation ∈ {OWNER,
MEMBER, COLLABORATOR}`. Contributor-author side threads do not block (same
qualifier as triage's
[`unresolved_threads_only`](../pr-management-triage/classify-and-act.md#unresolved_threads_only)).
|
| G7 | No outstanding changes-requested | no `latestReviews` node with `state
== CHANGES_REQUESTED` that is newer than the last commit. |
-**Conservative-on-uncertainty (Golden rule 4).** If `mergeable == UNKNOWN` or
-`mergeStateStatus == UNKNOWN` (GitHub hasn't finished computing mergeability),
-the PR **fails** G5 for this run — do not surface it. It will settle and
qualify
-on the next sweep. Never guess a gate green.
-
-A PR failing any gate is dropped with the corresponding
+**Why mergeability is deferred to a live re-poll.** GitHub computes `mergeable`
+on demand and caches it only briefly, so a single batched search over a large
+`ready` queue returns `mergeable == UNKNOWN` for many PRs and — more
+importantly — `mergeStateStatus == BLOCKED` for *most* ready PRs, because
branch
+protection is withholding the merge pending the required approval they do not
+have yet. Gating on the batch values here drops nearly the whole queue
+(observed on a real run: **177 of 204** ready PRs dropped on a batch
+mergeability gate, almost none of them actually conflicting — they were
+`BLOCKED` awaiting a committer approval, which is precisely the case this skill
+exists to clear). So Stage 1 only early-drops the batch-`CONFLICTING` PRs; true
+merge-readiness — including the `BLOCKED`-on-approval case — is resolved
+per-candidate in [Stage 3](#stage-3--live-merge-readiness), after triviality
has
+narrowed the set to a handful (one REST call each, cheap).
+
+A PR failing G1–G4/G6/G7 is dropped with the corresponding
[drop reason](#drop-reason-taxonomy) (`gate:<Gn>`) and excluded from Stage 2.
---
@@ -86,6 +95,41 @@ A PR passing 2a–2c is a candidate; assign its [tier](#tiers).
---
+## Stage 3 — live merge-readiness
+
+Stages 1–2 run over the batch fetch. For each survivor — now a handful, not the
+whole queue — resolve mergeability **live**: a direct
+`GET /repos/<repo>/pulls/<N>` forces GitHub to compute `mergeable` +
+`mergeable_state` fresh, which the batched search value cannot be trusted to
+reflect (see the [Stage 1 note](#stage-1--quality-gate)). One REST call per
+candidate; cheap because the set is already small.
+
+Classify each candidate by the live `(mergeable, mergeable_state)` pair:
+
+| Live state | Disposition |
+|---|---|
+| `mergeable == true`, `mergeable_state ∈ {clean, has_hooks}` | **Ready to
merge.** Surface in the *ready* bucket with the merge command. |
+| `mergeable == true`, `mergeable_state ∈ {unstable, behind}` | **Ready to
merge.** `unstable` is a *non-required* check still running/failed (G2/G3
already proved every required check green); `behind` is a stale-but-clean
branch GitHub fast-forwards. Surface in *ready*; note the state. |
+| `mergeable == true`, `mergeable_state == blocked` | **Needs your approval,
then merge.** The branch merges cleanly but branch protection withholds it —
and since Stage 1 proved CI green and no changes-requested, the withheld
requirement is the **required review**: the PR lacks a qualifying committer
approval. Surface in the *approval* bucket and route to the
[`[A]pprove`](SKILL.md#step-3b--optional-approve-action) action. **This is the
skill's primary case — most ready PRs sit here — n [...]
+| `mergeable == false` **or** `mergeable_state == dirty` | **Conflict → drop**
(`gate:G5-conflict`). |
+| `mergeable == null` / `mergeable_state == unknown` (even after the live
call) | **Still computing → drop this run** (`gate:G5-unknown`), conservative
per Golden rule 4. It settles and qualifies next run. |
+
+The `blocked` row is the load-bearing change. A ready PR that is trivial,
+all-green, and merges cleanly but simply has no committer approval yet is
exactly
+what the [`[A]pprove`](SKILL.md#step-3b--optional-approve-action) action exists
+for; treating `blocked` as a drop (as a naive batch gate does) hides the
skill's
+whole reason to exist.
+
+**Confirm 'blocked on review', not 'blocked on a required check.'** Stage 1's
+G2/G3 already established every check is green and done, so a `blocked` state
+here is review-required in the normal case. Where an adopter's branch
protection
+makes a *non*-CI context required, confirm with
+`gh pr view <N> --json reviewDecision` — `REVIEW_REQUIRED` ⇒ a missing approval
+is the blocker (route to the approval bucket); any other decision ⇒ drop, the
+block is not something an approval clears.
+
+---
+
## Tiers
| Tier | Meaning | Allow source | Confidence |
@@ -177,16 +221,20 @@ Every screened-out PR carries exactly one drop reason,
surfaced in the
| Reason | Meaning |
|---|---|
-| `gate:G2` … `gate:G7` | failed the named quality gate (CI red, conflict,
unresolved thread, …) |
+| `gate:G2` … `gate:G7` | failed the named Stage-1 quality gate (CI red,
unresolved thread, changes-requested, …) |
| `too-large` | gate-green but `churn > max_churn` or `files > max_files` |
| `path-denied` | a changed file matched `deny_globs` (consequential area) |
| `path-unmatched` | a changed file matched no allow glob (unknown area) |
+| `gate:G5-conflict` | Stage-3 live re-poll: genuine merge conflict
(`mergeable == false` / `dirty`) |
+| `gate:G5-unknown` | Stage-3 live re-poll: mergeability still uncomputed
after the direct call — dropped this run, qualifies next |
-`gate:*` drops are reported as a single count (the maintainer rarely cares
-*which* gate a non-ready-looking PR failed); `too-large`, `path-denied`, and
-`path-unmatched` are reported with PR numbers, because those are the
-"so-close" PRs a maintainer may want to glance at or hand to
-`pr-management-code-review`.
+`gate:*` and `gate:G5-unknown` drops are reported as a single count each (the
+maintainer rarely cares which one a non-ready-looking PR hit); `too-large`,
+`path-denied`, `path-unmatched`, and `gate:G5-conflict` are reported with PR
+numbers, because those are the "so-close" PRs a maintainer may want to glance
at
+or hand to `pr-management-code-review`. **Note:** a `BLOCKED` live state is
+**not** a drop reason — it is the *approval* bucket (see
+[Stage 3](#stage-3--live-merge-readiness)), the skill's primary output.
---
@@ -196,12 +244,22 @@ Extend the family batch query
([`pr-management-triage/fetch-and-batch.md`](../pr-management-triage/fetch-and-batch.md))
with the fields this screen needs beyond what triage already fetches:
-| Stage | Required fields (delta over the triage batch query) |
+| Stage | Required fields / calls (delta over the triage batch query) |
|---|---|
-| Stage 1 | `mergeStateStatus`; (`statusCheckRollup`, `mergeable`,
`reviewThreads`, `latestReviews`, `head_sha` already present) |
+| Stage 1 | batch `mergeable` (only the `CONFLICTING` early-drop);
(`statusCheckRollup`, `reviewThreads`, `latestReviews`, `head_sha` already
present) |
| Stage 2 | `additions`, `deletions`, `files(first: 100) { nodes { path
additions deletions } }` |
+| Stage 3 | **one REST call per surviving candidate** — `GET
/repos/<repo>/pulls/<N>` for live `mergeable` + `mergeable_state`; plus `gh pr
view <N> --json reviewDecision` only when disambiguating a `blocked` state |
Everything else (label list, author association, rollup contexts, the
`action_required` index) is already fetched by the shared family machinery.
-Golden rule 6 ("one query per page") still applies — the `files` connection
-rides along in the same paged call.
+Golden rule 6 ("one query per page") still applies to the Stage-1/2 batch — the
+`files` connection rides along in the same paged call. Stage 3's per-candidate
+REST calls are deliberately **not** batched: they are the unavoidable cost of
+forcing GitHub to compute mergeability, and they run only on the small
+post-triviality survivor set, not the whole queue.
+
+> **Note — `mergeStateStatus` from the batch is no longer gated on.** Earlier
+> drafts gated Stage 1 on the batch `mergeStateStatus`; that dropped ~87% of a
+> real `ready` queue because GitHub reports `BLOCKED`/`UNKNOWN` for most ready
+> PRs in a batch. The batch value is now informational only; Stage 3's live
+> re-poll is authoritative.