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.

Reply via email to