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.git


The following commit(s) were added to refs/heads/main by this push:
     new d4007e58bc2 Improve auto-triage: always include drafts, close stale 
ones, prefetch next page, show reviews (#64669)
d4007e58bc2 is described below

commit d4007e58bc21b2f5bb4ce9b92a2bfaba91a4e928
Author: Jarek Potiuk <[email protected]>
AuthorDate: Fri Apr 3 23:26:03 2026 +0200

    Improve auto-triage: always include drafts, close stale ones, prefetch next 
page, show reviews (#64669)
    
    - Remove --include-drafts flag; draft PRs always enter the triage pipeline
    - Non-stale draft PRs are filtered out from candidates and display
    - Stale drafts (triaged >7 days, no author response) are auto-closed with
      a comment inviting the author to reopen after addressing feedback
    - Add skip action (s) to workflow approval diff review prompts
    - Show review instructions before batch workflow approval
    - Wait for user confirmation before starting diff review
    - Background prefetch of next page at 75% of current batch in sequential 
mode
    - Show approval/rejection count and reviewer names in PR headers (both
      sequential and TUI modes)
    - Fetch review decisions via GraphQL and add ReviewDecision model
    - LLM prompt: treat generated files as auto-generated (CI checks handle 
them)
---
 dev/breeze/doc/13_pr_tasks.rst                     |   50 +-
 dev/breeze/doc/images/output_pr_auto-triage.svg    |   76 +-
 dev/breeze/doc/images/output_pr_auto-triage.txt    |    2 +-
 .../src/airflow_breeze/commands/pr_commands.py     | 1002 +++++++++++++++++---
 .../airflow_breeze/commands/pr_commands_config.py  |    1 -
 dev/breeze/src/airflow_breeze/utils/confirm.py     |   20 +-
 dev/breeze/src/airflow_breeze/utils/llm_utils.py   |    4 +-
 dev/breeze/src/airflow_breeze/utils/pr_cache.py    |   18 +-
 dev/breeze/src/airflow_breeze/utils/pr_display.py  |   14 +
 dev/breeze/src/airflow_breeze/utils/pr_models.py   |   12 +-
 dev/breeze/src/airflow_breeze/utils/tui_display.py |   23 +-
 11 files changed, 1031 insertions(+), 191 deletions(-)

diff --git a/dev/breeze/doc/13_pr_tasks.rst b/dev/breeze/doc/13_pr_tasks.rst
index d0f411f0703..61d46f9e32c 100644
--- a/dev/breeze/doc/13_pr_tasks.rst
+++ b/dev/breeze/doc/13_pr_tasks.rst
@@ -55,9 +55,18 @@ When the command starts, it runs through several startup 
phases with a progress
    mergeability.
 7. **Verify CI status** — Verifies that SUCCESS status is from real CI (not 
just bot/labeler
    checks).
-8. **Filter & classify** — Filters out collaborators, bots, drafts (optional), 
and applies
-   label/date/author filters.
+8. **Filter & classify** — Filters out collaborators, bots, and applies
+   label/date/author filters. Drafts are always included for staleness 
detection.
 9. **Check prior triage** — Checks if PRs already have triage comments from 
previous runs.
+10. **Detect stale PRs** — Identifies stale draft PRs and inactive open PRs 
for closing:
+
+    - **Triaged drafts** — Draft PRs with a triage comment older than 7 days 
and no author
+      response are marked for closing.
+    - **Non-triaged drafts** — Draft PRs with no activity (based on 
``updated_at``) for
+      over 3 weeks are marked for closing.
+    - **Inactive open PRs** — Non-draft PRs with no activity for over 4 weeks 
are marked
+      for closing.
+    - Non-stale drafts are skipped from triage (they stay in draft until they 
become stale).
 
 TUI mode (full-screen interactive)
 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
@@ -353,8 +362,20 @@ in sequential mode, presenting PRs one at a time:
 
 6. **Already triaged** — Previously triaged PRs offered for re-evaluation.
 
-Each PR info panel shows the PR Classification, LLM Review status, labels, and
-author profile.
+7. **Stale drafts** — Draft PRs that have been inactive are automatically 
closed:
+
+   - Triaged drafts with no author response for 7+ days
+   - Non-triaged drafts with no activity for 3+ weeks
+
+8. **Inactive open PRs** — Non-draft PRs with no activity for 4+ weeks are
+   automatically converted to draft with a comment asking the author to mark
+   as ready when they resume work.
+
+9. **Stale workflow-approval PRs** — PRs awaiting workflow approval with no 
activity
+   for 4+ weeks are automatically converted to draft with a comment.
+
+Each PR info panel shows the PR Classification, LLM Review status, maintainer
+reviews (approvals/changes requested from collaborators), labels, and author 
profile.
 
 Session summary
 ^^^^^^^^^^^^^^^
@@ -402,6 +423,27 @@ The command uses the following GitHub labels to track 
triage state:
 These labels must exist in the GitHub repository before using the command. If 
a label is
 missing, the command will print a warning and skip the labeling step.
 
+Automatic staleness detection and closing
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+The command automatically detects and proposes closing PRs that have gone 
stale.
+Different thresholds apply depending on the PR state:
+
+============================  ==========  ==========  
=============================================
+PR State                      Threshold   Action      Condition
+============================  ==========  ==========  
=============================================
+Triaged draft                 7 days      Close       Triage comment posted, 
no author response
+Non-triaged draft             3 weeks     Close       No activity 
(``updated_at``) from author
+Open (non-draft)              4 weeks     Draft       No activity 
(``updated_at``) from author
+Awaiting workflow approval    4 weeks     Draft       No activity 
(``updated_at``) from author
+============================  ==========  ==========  
=============================================
+
+For draft PRs that are stale, the PR is closed with a comment inviting the 
author to
+reopen. For non-draft PRs, the PR is converted to draft with a comment asking 
the
+author to mark as ready when they resume. All actions go through the normal 
triage
+action flow, so ``--dry-run`` will show what would happen without making 
changes.
+No labels are added when converting to draft — only a comment is posted.
+
 Example usage
 ^^^^^^^^^^^^^
 
diff --git a/dev/breeze/doc/images/output_pr_auto-triage.svg 
b/dev/breeze/doc/images/output_pr_auto-triage.svg
index c9302177d5e..23937ddcfc8 100644
--- a/dev/breeze/doc/images/output_pr_auto-triage.svg
+++ b/dev/breeze/doc/images/output_pr_auto-triage.svg
@@ -1,4 +1,4 @@
-<svg class="rich-terminal" viewBox="0 0 1482 2465.6" 
xmlns="http://www.w3.org/2000/svg";>
+<svg class="rich-terminal" viewBox="0 0 1482 2416.7999999999997" 
xmlns="http://www.w3.org/2000/svg";>
     <!-- Generated with Rich https://www.textualize.io -->
     <style>
 
@@ -43,7 +43,7 @@
 
     <defs>
     <clipPath id="breeze-pr-auto-triage-clip-terminal">
-      <rect x="0" y="0" width="1463.0" height="2414.6" />
+      <rect x="0" y="0" width="1463.0" height="2365.7999999999997" />
     </clipPath>
     <clipPath id="breeze-pr-auto-triage-line-0">
     <rect x="0" y="1.5" width="1464" height="24.65"/>
@@ -333,15 +333,9 @@
 <clipPath id="breeze-pr-auto-triage-line-95">
     <rect x="0" y="2319.5" width="1464" height="24.65"/>
             </clipPath>
-<clipPath id="breeze-pr-auto-triage-line-96">
-    <rect x="0" y="2343.9" width="1464" height="24.65"/>
-            </clipPath>
-<clipPath id="breeze-pr-auto-triage-line-97">
-    <rect x="0" y="2368.3" width="1464" height="24.65"/>
-            </clipPath>
     </defs>
 
-    <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" 
x="1" y="1" width="1480" height="2463.6" rx="8"/><text 
class="breeze-pr-auto-triage-title" fill="#c5c8c6" text-anchor="middle" x="740" 
y="27">Command:&#160;pr&#160;auto-triage</text>
+    <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" 
x="1" y="1" width="1480" height="2414.8" rx="8"/><text 
class="breeze-pr-auto-triage-title" fill="#c5c8c6" text-anchor="middle" x="740" 
y="27">Command:&#160;pr&#160;auto-triage</text>
             <g transform="translate(26,22)">
             <circle cx="0" cy="0" r="7" fill="#ff5f57"/>
             <circle cx="22" cy="0" r="7" fill="#febc2e"/>
@@ -417,39 +411,37 @@
 </text><text class="breeze-pr-auto-triage-r5" x="0" y="1557.2" 
textLength="12.2" clip-path="url(#breeze-pr-auto-triage-line-63)">│</text><text 
class="breeze-pr-auto-triage-r4" x="24.4" y="1557.2" textLength="280.6" 
clip-path="url(#breeze-pr-auto-triage-line-63)">--created-before&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text
 class="breeze-pr-auto-triage-r1" x="329.4" y="1557.2" textLength="658.8" 
clip-path="url(#breeze-pr-auto-triage-line-63)">Only&#160;PRs&#160;created&#160;on&#1
 [...]
 </text><text class="breeze-pr-auto-triage-r5" x="0" y="1581.6" 
textLength="12.2" clip-path="url(#breeze-pr-auto-triage-line-64)">│</text><text 
class="breeze-pr-auto-triage-r4" x="24.4" y="1581.6" textLength="280.6" 
clip-path="url(#breeze-pr-auto-triage-line-64)">--updated-after&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text
 class="breeze-pr-auto-triage-r1" x="329.4" y="1581.6" textLength="646.6" 
clip-path="url(#breeze-pr-auto-triage-line-64)">Only&#160;PRs&#160;updated&#160;
 [...]
 </text><text class="breeze-pr-auto-triage-r5" x="0" y="1606" textLength="12.2" 
clip-path="url(#breeze-pr-auto-triage-line-65)">│</text><text 
class="breeze-pr-auto-triage-r4" x="24.4" y="1606" textLength="280.6" 
clip-path="url(#breeze-pr-auto-triage-line-65)">--updated-before&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text
 class="breeze-pr-auto-triage-r1" x="329.4" y="1606" textLength="658.8" 
clip-path="url(#breeze-pr-auto-triage-line-65)">Only&#160;PRs&#160;updated&#160;on&#160;or&
 [...]
-</text><text class="breeze-pr-auto-triage-r5" x="0" y="1630.4" 
textLength="12.2" clip-path="url(#breeze-pr-auto-triage-line-66)">│</text><text 
class="breeze-pr-auto-triage-r4" x="24.4" y="1630.4" textLength="280.6" 
clip-path="url(#breeze-pr-auto-triage-line-66)">--include-drafts&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text
 class="breeze-pr-auto-triage-r1" x="329.4" y="1630.4" textLength="1110.2" 
clip-path="url(#breeze-pr-auto-triage-line-66)">Include&#160;draft&#160;PRs&#160;in&
 [...]
-</text><text class="breeze-pr-auto-triage-r5" x="0" y="1654.8" 
textLength="12.2" clip-path="url(#breeze-pr-auto-triage-line-67)">│</text><text 
class="breeze-pr-auto-triage-r1" x="329.4" y="1654.8" textLength="1110.2" 
clip-path="url(#breeze-pr-auto-triage-line-67)">review.&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#16
 [...]
-</text><text class="breeze-pr-auto-triage-r5" x="0" y="1679.2" 
textLength="12.2" clip-path="url(#breeze-pr-auto-triage-line-68)">│</text><text 
class="breeze-pr-auto-triage-r4" x="24.4" y="1679.2" textLength="280.6" 
clip-path="url(#breeze-pr-auto-triage-line-68)">--pending-approval-only</text><text
 class="breeze-pr-auto-triage-r1" x="329.4" y="1679.2" textLength="622.2" 
clip-path="url(#breeze-pr-auto-triage-line-68)">Only&#160;show&#160;PRs&#160;with&#160;workflow&#160;runs&#160;awaiting&
 [...]
-</text><text class="breeze-pr-auto-triage-r5" x="0" y="1703.6" 
textLength="12.2" clip-path="url(#breeze-pr-auto-triage-line-69)">│</text><text 
class="breeze-pr-auto-triage-r4" x="24.4" y="1703.6" textLength="280.6" 
clip-path="url(#breeze-pr-auto-triage-line-69)">--checks-state&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text
 class="breeze-pr-auto-triage-r1" x="329.4" y="1703.6" textLength="524.6" 
clip-path="url(#breeze-pr-auto-triage-line-69)">Only&#160;assess&#160;PRs&# 
[...]
-</text><text class="breeze-pr-auto-triage-r5" x="0" y="1728" textLength="12.2" 
clip-path="url(#breeze-pr-auto-triage-line-70)">│</text><text 
class="breeze-pr-auto-triage-r4" x="24.4" y="1728" textLength="280.6" 
clip-path="url(#breeze-pr-auto-triage-line-70)">--min-commits-behind&#160;&#160;&#160;</text><text
 class="breeze-pr-auto-triage-r1" x="329.4" y="1728" textLength="878.4" 
clip-path="url(#breeze-pr-auto-triage-line-70)">Only&#160;assess&#160;PRs&#160;that&#160;are&#160;at&#160;least
 [...]
-</text><text class="breeze-pr-auto-triage-r5" x="0" y="1752.4" 
textLength="12.2" clip-path="url(#breeze-pr-auto-triage-line-71)">│</text><text 
class="breeze-pr-auto-triage-r6" x="329.4" y="1752.4" textLength="109.8" 
clip-path="url(#breeze-pr-auto-triage-line-71)">(INTEGER)</text><text 
class="breeze-pr-auto-triage-r5" x="1451.8" y="1752.4" textLength="12.2" 
clip-path="url(#breeze-pr-auto-triage-line-71)">│</text><text 
class="breeze-pr-auto-triage-r1" x="1464" y="1752.4" textLength="12.2"  [...]
-</text><text class="breeze-pr-auto-triage-r5" x="0" y="1776.8" 
textLength="1464" 
clip-path="url(#breeze-pr-auto-triage-line-72)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
 class="breeze-pr-auto-triage-r1" x="1464" y="1776.8" textLength="12.2" 
clip-path="url(#breeze-pr-auto-triage-line-72)">
-</text><text class="breeze-pr-auto-triage-r5" x="0" y="1801.2" 
textLength="24.4" 
clip-path="url(#breeze-pr-auto-triage-line-73)">╭─</text><text 
class="breeze-pr-auto-triage-r5" x="24.4" y="1801.2" textLength="292.8" 
clip-path="url(#breeze-pr-auto-triage-line-73)">&#160;Pagination&#160;and&#160;sorting&#160;</text><text
 class="breeze-pr-auto-triage-r5" x="317.2" y="1801.2" textLength="1122.4" 
clip-path="url(#breeze-pr-auto-triage-line-73)">─────────────────────────────────────────────────
 [...]
-</text><text class="breeze-pr-auto-triage-r5" x="0" y="1825.6" 
textLength="12.2" clip-path="url(#breeze-pr-auto-triage-line-74)">│</text><text 
class="breeze-pr-auto-triage-r4" x="24.4" y="1825.6" textLength="146.4" 
clip-path="url(#breeze-pr-auto-triage-line-74)">--batch-size</text><text 
class="breeze-pr-auto-triage-r1" x="195.2" y="1825.6" textLength="500.2" 
clip-path="url(#breeze-pr-auto-triage-line-74)">Number&#160;of&#160;PRs&#160;to&#160;fetch&#160;per&#160;GraphQL&#160;page.&#160;</
 [...]
-</text><text class="breeze-pr-auto-triage-r5" x="0" y="1850" textLength="12.2" 
clip-path="url(#breeze-pr-auto-triage-line-75)">│</text><text 
class="breeze-pr-auto-triage-r4" x="24.4" y="1850" textLength="146.4" 
clip-path="url(#breeze-pr-auto-triage-line-75)">--max-num&#160;&#160;&#160;</text><text
 class="breeze-pr-auto-triage-r1" x="195.2" y="1850" textLength="793" 
clip-path="url(#breeze-pr-auto-triage-line-75)">Maximum&#160;number&#160;of&#160;non-collaborator&#160;PRs&#160;to&#160;asse
 [...]
-</text><text class="breeze-pr-auto-triage-r5" x="0" y="1874.4" 
textLength="12.2" clip-path="url(#breeze-pr-auto-triage-line-76)">│</text><text 
class="breeze-pr-auto-triage-r4" x="24.4" y="1874.4" textLength="146.4" 
clip-path="url(#breeze-pr-auto-triage-line-76)">--sort&#160;&#160;&#160;&#160;&#160;&#160;</text><text
 class="breeze-pr-auto-triage-r1" x="195.2" y="1874.4" textLength="414.8" 
clip-path="url(#breeze-pr-auto-triage-line-76)">Sort&#160;order&#160;for&#160;PR&#160;search&#160;res
 [...]
-</text><text class="breeze-pr-auto-triage-r5" x="0" y="1898.8" 
textLength="12.2" clip-path="url(#breeze-pr-auto-triage-line-77)">│</text><text 
class="breeze-pr-auto-triage-r6" x="195.2" y="1898.8" textLength="622.2" 
clip-path="url(#breeze-pr-auto-triage-line-77)">(created-asc|created-desc|updated-asc|updated-desc)</text><text
 class="breeze-pr-auto-triage-r5" x="1451.8" y="1898.8" textLength="12.2" 
clip-path="url(#breeze-pr-auto-triage-line-77)">│</text><text 
class="breeze-pr-auto-triage- [...]
-</text><text class="breeze-pr-auto-triage-r5" x="0" y="1923.2" 
textLength="1464" 
clip-path="url(#breeze-pr-auto-triage-line-78)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
 class="breeze-pr-auto-triage-r1" x="1464" y="1923.2" textLength="12.2" 
clip-path="url(#breeze-pr-auto-triage-line-78)">
-</text><text class="breeze-pr-auto-triage-r5" x="0" y="1947.6" 
textLength="24.4" 
clip-path="url(#breeze-pr-auto-triage-line-79)">╭─</text><text 
class="breeze-pr-auto-triage-r5" x="24.4" y="1947.6" textLength="244" 
clip-path="url(#breeze-pr-auto-triage-line-79)">&#160;Assessment&#160;options&#160;</text><text
 class="breeze-pr-auto-triage-r5" x="268.4" y="1947.6" textLength="1171.2" 
clip-path="url(#breeze-pr-auto-triage-line-79)">────────────────────────────────────────────────────────────
 [...]
-</text><text class="breeze-pr-auto-triage-r5" x="0" y="1972" textLength="12.2" 
clip-path="url(#breeze-pr-auto-triage-line-80)">│</text><text 
class="breeze-pr-auto-triage-r4" x="24.4" y="1972" textLength="207.4" 
clip-path="url(#breeze-pr-auto-triage-line-80)">--llm-model&#160;&#160;&#160;&#160;&#160;&#160;</text><text
 class="breeze-pr-auto-triage-r1" x="256.2" y="1972" textLength="1183.4" 
clip-path="url(#breeze-pr-auto-triage-line-80)">LLM&#160;model&#160;for&#160;assessment&#160;(format:
 [...]
-</text><text class="breeze-pr-auto-triage-r5" x="0" y="1996.4" 
textLength="12.2" clip-path="url(#breeze-pr-auto-triage-line-81)">│</text><text 
class="breeze-pr-auto-triage-r1" x="256.2" y="1996.4" textLength="268.4" 
clip-path="url(#breeze-pr-auto-triage-line-81)">for&#160;OpenAI&#160;Codex&#160;CLI.&#160;</text><text
 class="breeze-pr-auto-triage-r5" x="524.6" y="1996.4" textLength="427" 
clip-path="url(#breeze-pr-auto-triage-line-81)">[default:&#160;claude/claude-sonnet-4-6]</text><text
 c [...]
-</text><text class="breeze-pr-auto-triage-r5" x="0" y="2020.8" 
textLength="12.2" clip-path="url(#breeze-pr-auto-triage-line-82)">│</text><text 
class="breeze-pr-auto-triage-r6" x="256.2" y="2020.8" textLength="1159" 
clip-path="url(#breeze-pr-auto-triage-line-82)">&gt;claude/claude-sonnet-4-6&lt;&#160;|&#160;claude/claude-opus-4-20250514&#160;|&#160;claude/claude-sonnet-4-20250514&#160;|&#160;</text><text
 class="breeze-pr-auto-triage-r5" x="1451.8" y="2020.8" textLength="12.2" 
clip-path="u [...]
-</text><text class="breeze-pr-auto-triage-r5" x="0" y="2045.2" 
textLength="12.2" clip-path="url(#breeze-pr-auto-triage-line-83)">│</text><text 
class="breeze-pr-auto-triage-r6" x="256.2" y="2045.2" textLength="1110.2" 
clip-path="url(#breeze-pr-auto-triage-line-83)">claude/claude-haiku-4-5-20251001&#160;|&#160;claude/sonnet&#160;|&#160;claude/opus&#160;|&#160;claude/haiku&#160;|&#160;codex/o3&#160;|&#160;</text><text
 class="breeze-pr-auto-triage-r5" x="1451.8" y="2045.2" textLength="12.2"  [...]
-</text><text class="breeze-pr-auto-triage-r5" x="0" y="2069.6" 
textLength="12.2" clip-path="url(#breeze-pr-auto-triage-line-84)">│</text><text 
class="breeze-pr-auto-triage-r6" x="256.2" y="2069.6" textLength="366" 
clip-path="url(#breeze-pr-auto-triage-line-84)">codex/o4-mini&#160;|&#160;codex/gpt-4.1)</text><text
 class="breeze-pr-auto-triage-r5" x="1451.8" y="2069.6" textLength="12.2" 
clip-path="url(#breeze-pr-auto-triage-line-84)">│</text><text 
class="breeze-pr-auto-triage-r1" x="1464"  [...]
-</text><text class="breeze-pr-auto-triage-r5" x="0" y="2094" textLength="12.2" 
clip-path="url(#breeze-pr-auto-triage-line-85)">│</text><text 
class="breeze-pr-auto-triage-r4" x="24.4" y="2094" textLength="207.4" 
clip-path="url(#breeze-pr-auto-triage-line-85)">--llm-concurrency</text><text 
class="breeze-pr-auto-triage-r1" x="256.2" y="2094" textLength="866.2" 
clip-path="url(#breeze-pr-auto-triage-line-85)">Number&#160;of&#160;concurrent&#160;LLM&#160;assessment&#160;calls&#160;(default:&#1
 [...]
-</text><text class="breeze-pr-auto-triage-r5" x="0" y="2118.4" 
textLength="12.2" clip-path="url(#breeze-pr-auto-triage-line-86)">│</text><text 
class="breeze-pr-auto-triage-r4" x="24.4" y="2118.4" textLength="207.4" 
clip-path="url(#breeze-pr-auto-triage-line-86)">--clear-cache&#160;&#160;&#160;&#160;</text><text
 class="breeze-pr-auto-triage-r1" x="256.2" y="2118.4" textLength="658.8" 
clip-path="url(#breeze-pr-auto-triage-line-86)">Clear&#160;the&#160;LLM&#160;review&#160;and&#160;triage&#
 [...]
-</text><text class="breeze-pr-auto-triage-r5" x="0" y="2142.8" 
textLength="1464" 
clip-path="url(#breeze-pr-auto-triage-line-87)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
 class="breeze-pr-auto-triage-r1" x="1464" y="2142.8" textLength="12.2" 
clip-path="url(#breeze-pr-auto-triage-line-87)">
-</text><text class="breeze-pr-auto-triage-r5" x="0" y="2167.2" 
textLength="24.4" 
clip-path="url(#breeze-pr-auto-triage-line-88)">╭─</text><text 
class="breeze-pr-auto-triage-r5" x="24.4" y="2167.2" textLength="85.4" 
clip-path="url(#breeze-pr-auto-triage-line-88)">&#160;Other&#160;</text><text 
class="breeze-pr-auto-triage-r5" x="109.8" y="2167.2" textLength="1329.8" 
clip-path="url(#breeze-pr-auto-triage-line-88)">─────────────────────────────────────────────────────────────────────────────
 [...]
-</text><text class="breeze-pr-auto-triage-r5" x="0" y="2191.6" 
textLength="12.2" clip-path="url(#breeze-pr-auto-triage-line-89)">│</text><text 
class="breeze-pr-auto-triage-r4" x="24.4" y="2191.6" textLength="231.8" 
clip-path="url(#breeze-pr-auto-triage-line-89)">--answer-triage&#160;&#160;&#160;&#160;</text><text
 class="breeze-pr-auto-triage-r1" x="329.4" y="2191.6" textLength="1110.2" 
clip-path="url(#breeze-pr-auto-triage-line-89)">Force&#160;answer&#160;to&#160;triage&#160;prompts:&#16
 [...]
-</text><text class="breeze-pr-auto-triage-r5" x="0" y="2216" textLength="12.2" 
clip-path="url(#breeze-pr-auto-triage-line-90)">│</text><text 
class="breeze-pr-auto-triage-r6" x="329.4" y="2216" textLength="183" 
clip-path="url(#breeze-pr-auto-triage-line-90)">(d|c|r|s|q|y|n)</text><text 
class="breeze-pr-auto-triage-r5" x="1451.8" y="2216" textLength="12.2" 
clip-path="url(#breeze-pr-auto-triage-line-90)">│</text><text 
class="breeze-pr-auto-triage-r1" x="1464" y="2216" textLength="12.2" clip [...]
-</text><text class="breeze-pr-auto-triage-r5" x="0" y="2240.4" 
textLength="12.2" clip-path="url(#breeze-pr-auto-triage-line-91)">│</text><text 
class="breeze-pr-auto-triage-r4" x="24.4" y="2240.4" textLength="231.8" 
clip-path="url(#breeze-pr-auto-triage-line-91)">--github-token&#160;&#160;&#160;&#160;&#160;</text><text
 class="breeze-pr-auto-triage-r1" x="329.4" y="2240.4" textLength="512.4" 
clip-path="url(#breeze-pr-auto-triage-line-91)">The&#160;token&#160;used&#160;to&#160;authenticate&
 [...]
-</text><text class="breeze-pr-auto-triage-r5" x="0" y="2264.8" 
textLength="12.2" clip-path="url(#breeze-pr-auto-triage-line-92)">│</text><text 
class="breeze-pr-auto-triage-r4" x="24.4" y="2264.8" textLength="231.8" 
clip-path="url(#breeze-pr-auto-triage-line-92)">--github-repository</text><text 
class="breeze-pr-auto-triage-r7" x="280.6" y="2264.8" textLength="24.4" 
clip-path="url(#breeze-pr-auto-triage-line-92)">-g</text><text 
class="breeze-pr-auto-triage-r1" x="329.4" y="2264.8" textLeng [...]
-</text><text class="breeze-pr-auto-triage-r5" x="0" y="2289.2" 
textLength="1464" 
clip-path="url(#breeze-pr-auto-triage-line-93)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
 class="breeze-pr-auto-triage-r1" x="1464" y="2289.2" textLength="12.2" 
clip-path="url(#breeze-pr-auto-triage-line-93)">
-</text><text class="breeze-pr-auto-triage-r5" x="0" y="2313.6" 
textLength="24.4" 
clip-path="url(#breeze-pr-auto-triage-line-94)">╭─</text><text 
class="breeze-pr-auto-triage-r5" x="24.4" y="2313.6" textLength="195.2" 
clip-path="url(#breeze-pr-auto-triage-line-94)">&#160;Common&#160;options&#160;</text><text
 class="breeze-pr-auto-triage-r5" x="219.6" y="2313.6" textLength="1220" 
clip-path="url(#breeze-pr-auto-triage-line-94)">────────────────────────────────────────────────────────────────
 [...]
-</text><text class="breeze-pr-auto-triage-r5" x="0" y="2338" textLength="12.2" 
clip-path="url(#breeze-pr-auto-triage-line-95)">│</text><text 
class="breeze-pr-auto-triage-r4" x="24.4" y="2338" textLength="109.8" 
clip-path="url(#breeze-pr-auto-triage-line-95)">--dry-run</text><text 
class="breeze-pr-auto-triage-r7" x="158.6" y="2338" textLength="24.4" 
clip-path="url(#breeze-pr-auto-triage-line-95)">-D</text><text 
class="breeze-pr-auto-triage-r1" x="207.4" y="2338" textLength="719.8" clip-pa 
[...]
-</text><text class="breeze-pr-auto-triage-r5" x="0" y="2362.4" 
textLength="12.2" clip-path="url(#breeze-pr-auto-triage-line-96)">│</text><text 
class="breeze-pr-auto-triage-r4" x="24.4" y="2362.4" textLength="109.8" 
clip-path="url(#breeze-pr-auto-triage-line-96)">--verbose</text><text 
class="breeze-pr-auto-triage-r7" x="158.6" y="2362.4" textLength="24.4" 
clip-path="url(#breeze-pr-auto-triage-line-96)">-v</text><text 
class="breeze-pr-auto-triage-r1" x="207.4" y="2362.4" textLength="585.6" [...]
-</text><text class="breeze-pr-auto-triage-r5" x="0" y="2386.8" 
textLength="12.2" clip-path="url(#breeze-pr-auto-triage-line-97)">│</text><text 
class="breeze-pr-auto-triage-r4" x="24.4" y="2386.8" textLength="109.8" 
clip-path="url(#breeze-pr-auto-triage-line-97)">--help&#160;&#160;&#160;</text><text
 class="breeze-pr-auto-triage-r7" x="158.6" y="2386.8" textLength="24.4" 
clip-path="url(#breeze-pr-auto-triage-line-97)">-h</text><text 
class="breeze-pr-auto-triage-r1" x="207.4" y="2386.8" tex [...]
-</text><text class="breeze-pr-auto-triage-r5" x="0" y="2411.2" 
textLength="1464" 
clip-path="url(#breeze-pr-auto-triage-line-98)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
 class="breeze-pr-auto-triage-r1" x="1464" y="2411.2" textLength="12.2" 
clip-path="url(#breeze-pr-auto-triage-line-98)">
+</text><text class="breeze-pr-auto-triage-r5" x="0" y="1630.4" 
textLength="12.2" clip-path="url(#breeze-pr-auto-triage-line-66)">│</text><text 
class="breeze-pr-auto-triage-r4" x="24.4" y="1630.4" textLength="280.6" 
clip-path="url(#breeze-pr-auto-triage-line-66)">--pending-approval-only</text><text
 class="breeze-pr-auto-triage-r1" x="329.4" y="1630.4" textLength="622.2" 
clip-path="url(#breeze-pr-auto-triage-line-66)">Only&#160;show&#160;PRs&#160;with&#160;workflow&#160;runs&#160;awaiting&
 [...]
+</text><text class="breeze-pr-auto-triage-r5" x="0" y="1654.8" 
textLength="12.2" clip-path="url(#breeze-pr-auto-triage-line-67)">│</text><text 
class="breeze-pr-auto-triage-r4" x="24.4" y="1654.8" textLength="280.6" 
clip-path="url(#breeze-pr-auto-triage-line-67)">--checks-state&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text
 class="breeze-pr-auto-triage-r1" x="329.4" y="1654.8" textLength="524.6" 
clip-path="url(#breeze-pr-auto-triage-line-67)">Only&#160;assess&#160;PRs&# 
[...]
+</text><text class="breeze-pr-auto-triage-r5" x="0" y="1679.2" 
textLength="12.2" clip-path="url(#breeze-pr-auto-triage-line-68)">│</text><text 
class="breeze-pr-auto-triage-r4" x="24.4" y="1679.2" textLength="280.6" 
clip-path="url(#breeze-pr-auto-triage-line-68)">--min-commits-behind&#160;&#160;&#160;</text><text
 class="breeze-pr-auto-triage-r1" x="329.4" y="1679.2" textLength="878.4" 
clip-path="url(#breeze-pr-auto-triage-line-68)">Only&#160;assess&#160;PRs&#160;that&#160;are&#160;at&#160
 [...]
+</text><text class="breeze-pr-auto-triage-r5" x="0" y="1703.6" 
textLength="12.2" clip-path="url(#breeze-pr-auto-triage-line-69)">│</text><text 
class="breeze-pr-auto-triage-r6" x="329.4" y="1703.6" textLength="109.8" 
clip-path="url(#breeze-pr-auto-triage-line-69)">(INTEGER)</text><text 
class="breeze-pr-auto-triage-r5" x="1451.8" y="1703.6" textLength="12.2" 
clip-path="url(#breeze-pr-auto-triage-line-69)">│</text><text 
class="breeze-pr-auto-triage-r1" x="1464" y="1703.6" textLength="12.2"  [...]
+</text><text class="breeze-pr-auto-triage-r5" x="0" y="1728" textLength="1464" 
clip-path="url(#breeze-pr-auto-triage-line-70)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
 class="breeze-pr-auto-triage-r1" x="1464" y="1728" textLength="12.2" 
clip-path="url(#breeze-pr-auto-triage-line-70)">
+</text><text class="breeze-pr-auto-triage-r5" x="0" y="1752.4" 
textLength="24.4" 
clip-path="url(#breeze-pr-auto-triage-line-71)">╭─</text><text 
class="breeze-pr-auto-triage-r5" x="24.4" y="1752.4" textLength="292.8" 
clip-path="url(#breeze-pr-auto-triage-line-71)">&#160;Pagination&#160;and&#160;sorting&#160;</text><text
 class="breeze-pr-auto-triage-r5" x="317.2" y="1752.4" textLength="1122.4" 
clip-path="url(#breeze-pr-auto-triage-line-71)">─────────────────────────────────────────────────
 [...]
+</text><text class="breeze-pr-auto-triage-r5" x="0" y="1776.8" 
textLength="12.2" clip-path="url(#breeze-pr-auto-triage-line-72)">│</text><text 
class="breeze-pr-auto-triage-r4" x="24.4" y="1776.8" textLength="146.4" 
clip-path="url(#breeze-pr-auto-triage-line-72)">--batch-size</text><text 
class="breeze-pr-auto-triage-r1" x="195.2" y="1776.8" textLength="500.2" 
clip-path="url(#breeze-pr-auto-triage-line-72)">Number&#160;of&#160;PRs&#160;to&#160;fetch&#160;per&#160;GraphQL&#160;page.&#160;</
 [...]
+</text><text class="breeze-pr-auto-triage-r5" x="0" y="1801.2" 
textLength="12.2" clip-path="url(#breeze-pr-auto-triage-line-73)">│</text><text 
class="breeze-pr-auto-triage-r4" x="24.4" y="1801.2" textLength="146.4" 
clip-path="url(#breeze-pr-auto-triage-line-73)">--max-num&#160;&#160;&#160;</text><text
 class="breeze-pr-auto-triage-r1" x="195.2" y="1801.2" textLength="793" 
clip-path="url(#breeze-pr-auto-triage-line-73)">Maximum&#160;number&#160;of&#160;non-collaborator&#160;PRs&#160;to&#16
 [...]
+</text><text class="breeze-pr-auto-triage-r5" x="0" y="1825.6" 
textLength="12.2" clip-path="url(#breeze-pr-auto-triage-line-74)">│</text><text 
class="breeze-pr-auto-triage-r4" x="24.4" y="1825.6" textLength="146.4" 
clip-path="url(#breeze-pr-auto-triage-line-74)">--sort&#160;&#160;&#160;&#160;&#160;&#160;</text><text
 class="breeze-pr-auto-triage-r1" x="195.2" y="1825.6" textLength="414.8" 
clip-path="url(#breeze-pr-auto-triage-line-74)">Sort&#160;order&#160;for&#160;PR&#160;search&#160;res
 [...]
+</text><text class="breeze-pr-auto-triage-r5" x="0" y="1850" textLength="12.2" 
clip-path="url(#breeze-pr-auto-triage-line-75)">│</text><text 
class="breeze-pr-auto-triage-r6" x="195.2" y="1850" textLength="622.2" 
clip-path="url(#breeze-pr-auto-triage-line-75)">(created-asc|created-desc|updated-asc|updated-desc)</text><text
 class="breeze-pr-auto-triage-r5" x="1451.8" y="1850" textLength="12.2" 
clip-path="url(#breeze-pr-auto-triage-line-75)">│</text><text 
class="breeze-pr-auto-triage-r1" x= [...]
+</text><text class="breeze-pr-auto-triage-r5" x="0" y="1874.4" 
textLength="1464" 
clip-path="url(#breeze-pr-auto-triage-line-76)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
 class="breeze-pr-auto-triage-r1" x="1464" y="1874.4" textLength="12.2" 
clip-path="url(#breeze-pr-auto-triage-line-76)">
+</text><text class="breeze-pr-auto-triage-r5" x="0" y="1898.8" 
textLength="24.4" 
clip-path="url(#breeze-pr-auto-triage-line-77)">╭─</text><text 
class="breeze-pr-auto-triage-r5" x="24.4" y="1898.8" textLength="244" 
clip-path="url(#breeze-pr-auto-triage-line-77)">&#160;Assessment&#160;options&#160;</text><text
 class="breeze-pr-auto-triage-r5" x="268.4" y="1898.8" textLength="1171.2" 
clip-path="url(#breeze-pr-auto-triage-line-77)">────────────────────────────────────────────────────────────
 [...]
+</text><text class="breeze-pr-auto-triage-r5" x="0" y="1923.2" 
textLength="12.2" clip-path="url(#breeze-pr-auto-triage-line-78)">│</text><text 
class="breeze-pr-auto-triage-r4" x="24.4" y="1923.2" textLength="207.4" 
clip-path="url(#breeze-pr-auto-triage-line-78)">--llm-model&#160;&#160;&#160;&#160;&#160;&#160;</text><text
 class="breeze-pr-auto-triage-r1" x="256.2" y="1923.2" textLength="1183.4" 
clip-path="url(#breeze-pr-auto-triage-line-78)">LLM&#160;model&#160;for&#160;assessment&#160;(f
 [...]
+</text><text class="breeze-pr-auto-triage-r5" x="0" y="1947.6" 
textLength="12.2" clip-path="url(#breeze-pr-auto-triage-line-79)">│</text><text 
class="breeze-pr-auto-triage-r1" x="256.2" y="1947.6" textLength="268.4" 
clip-path="url(#breeze-pr-auto-triage-line-79)">for&#160;OpenAI&#160;Codex&#160;CLI.&#160;</text><text
 class="breeze-pr-auto-triage-r5" x="524.6" y="1947.6" textLength="427" 
clip-path="url(#breeze-pr-auto-triage-line-79)">[default:&#160;claude/claude-sonnet-4-6]</text><text
 c [...]
+</text><text class="breeze-pr-auto-triage-r5" x="0" y="1972" textLength="12.2" 
clip-path="url(#breeze-pr-auto-triage-line-80)">│</text><text 
class="breeze-pr-auto-triage-r6" x="256.2" y="1972" textLength="1159" 
clip-path="url(#breeze-pr-auto-triage-line-80)">&gt;claude/claude-sonnet-4-6&lt;&#160;|&#160;claude/claude-opus-4-20250514&#160;|&#160;claude/claude-sonnet-4-20250514&#160;|&#160;</text><text
 class="breeze-pr-auto-triage-r5" x="1451.8" y="1972" textLength="12.2" 
clip-path="url(#br [...]
+</text><text class="breeze-pr-auto-triage-r5" x="0" y="1996.4" 
textLength="12.2" clip-path="url(#breeze-pr-auto-triage-line-81)">│</text><text 
class="breeze-pr-auto-triage-r6" x="256.2" y="1996.4" textLength="1110.2" 
clip-path="url(#breeze-pr-auto-triage-line-81)">claude/claude-haiku-4-5-20251001&#160;|&#160;claude/sonnet&#160;|&#160;claude/opus&#160;|&#160;claude/haiku&#160;|&#160;codex/o3&#160;|&#160;</text><text
 class="breeze-pr-auto-triage-r5" x="1451.8" y="1996.4" textLength="12.2"  [...]
+</text><text class="breeze-pr-auto-triage-r5" x="0" y="2020.8" 
textLength="12.2" clip-path="url(#breeze-pr-auto-triage-line-82)">│</text><text 
class="breeze-pr-auto-triage-r6" x="256.2" y="2020.8" textLength="366" 
clip-path="url(#breeze-pr-auto-triage-line-82)">codex/o4-mini&#160;|&#160;codex/gpt-4.1)</text><text
 class="breeze-pr-auto-triage-r5" x="1451.8" y="2020.8" textLength="12.2" 
clip-path="url(#breeze-pr-auto-triage-line-82)">│</text><text 
class="breeze-pr-auto-triage-r1" x="1464"  [...]
+</text><text class="breeze-pr-auto-triage-r5" x="0" y="2045.2" 
textLength="12.2" clip-path="url(#breeze-pr-auto-triage-line-83)">│</text><text 
class="breeze-pr-auto-triage-r4" x="24.4" y="2045.2" textLength="207.4" 
clip-path="url(#breeze-pr-auto-triage-line-83)">--llm-concurrency</text><text 
class="breeze-pr-auto-triage-r1" x="256.2" y="2045.2" textLength="866.2" 
clip-path="url(#breeze-pr-auto-triage-line-83)">Number&#160;of&#160;concurrent&#160;LLM&#160;assessment&#160;calls&#160;(defau
 [...]
+</text><text class="breeze-pr-auto-triage-r5" x="0" y="2069.6" 
textLength="12.2" clip-path="url(#breeze-pr-auto-triage-line-84)">│</text><text 
class="breeze-pr-auto-triage-r4" x="24.4" y="2069.6" textLength="207.4" 
clip-path="url(#breeze-pr-auto-triage-line-84)">--clear-cache&#160;&#160;&#160;&#160;</text><text
 class="breeze-pr-auto-triage-r1" x="256.2" y="2069.6" textLength="658.8" 
clip-path="url(#breeze-pr-auto-triage-line-84)">Clear&#160;the&#160;LLM&#160;review&#160;and&#160;triage&#
 [...]
+</text><text class="breeze-pr-auto-triage-r5" x="0" y="2094" textLength="1464" 
clip-path="url(#breeze-pr-auto-triage-line-85)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
 class="breeze-pr-auto-triage-r1" x="1464" y="2094" textLength="12.2" 
clip-path="url(#breeze-pr-auto-triage-line-85)">
+</text><text class="breeze-pr-auto-triage-r5" x="0" y="2118.4" 
textLength="24.4" 
clip-path="url(#breeze-pr-auto-triage-line-86)">╭─</text><text 
class="breeze-pr-auto-triage-r5" x="24.4" y="2118.4" textLength="85.4" 
clip-path="url(#breeze-pr-auto-triage-line-86)">&#160;Other&#160;</text><text 
class="breeze-pr-auto-triage-r5" x="109.8" y="2118.4" textLength="1329.8" 
clip-path="url(#breeze-pr-auto-triage-line-86)">─────────────────────────────────────────────────────────────────────────────
 [...]
+</text><text class="breeze-pr-auto-triage-r5" x="0" y="2142.8" 
textLength="12.2" clip-path="url(#breeze-pr-auto-triage-line-87)">│</text><text 
class="breeze-pr-auto-triage-r4" x="24.4" y="2142.8" textLength="231.8" 
clip-path="url(#breeze-pr-auto-triage-line-87)">--answer-triage&#160;&#160;&#160;&#160;</text><text
 class="breeze-pr-auto-triage-r1" x="329.4" y="2142.8" textLength="1110.2" 
clip-path="url(#breeze-pr-auto-triage-line-87)">Force&#160;answer&#160;to&#160;triage&#160;prompts:&#16
 [...]
+</text><text class="breeze-pr-auto-triage-r5" x="0" y="2167.2" 
textLength="12.2" clip-path="url(#breeze-pr-auto-triage-line-88)">│</text><text 
class="breeze-pr-auto-triage-r6" x="329.4" y="2167.2" textLength="183" 
clip-path="url(#breeze-pr-auto-triage-line-88)">(d|c|r|s|q|y|n)</text><text 
class="breeze-pr-auto-triage-r5" x="1451.8" y="2167.2" textLength="12.2" 
clip-path="url(#breeze-pr-auto-triage-line-88)">│</text><text 
class="breeze-pr-auto-triage-r1" x="1464" y="2167.2" textLength="12 [...]
+</text><text class="breeze-pr-auto-triage-r5" x="0" y="2191.6" 
textLength="12.2" clip-path="url(#breeze-pr-auto-triage-line-89)">│</text><text 
class="breeze-pr-auto-triage-r4" x="24.4" y="2191.6" textLength="231.8" 
clip-path="url(#breeze-pr-auto-triage-line-89)">--github-token&#160;&#160;&#160;&#160;&#160;</text><text
 class="breeze-pr-auto-triage-r1" x="329.4" y="2191.6" textLength="512.4" 
clip-path="url(#breeze-pr-auto-triage-line-89)">The&#160;token&#160;used&#160;to&#160;authenticate&
 [...]
+</text><text class="breeze-pr-auto-triage-r5" x="0" y="2216" textLength="12.2" 
clip-path="url(#breeze-pr-auto-triage-line-90)">│</text><text 
class="breeze-pr-auto-triage-r4" x="24.4" y="2216" textLength="231.8" 
clip-path="url(#breeze-pr-auto-triage-line-90)">--github-repository</text><text 
class="breeze-pr-auto-triage-r7" x="280.6" y="2216" textLength="24.4" 
clip-path="url(#breeze-pr-auto-triage-line-90)">-g</text><text 
class="breeze-pr-auto-triage-r1" x="329.4" y="2216" textLength="597. [...]
+</text><text class="breeze-pr-auto-triage-r5" x="0" y="2240.4" 
textLength="1464" 
clip-path="url(#breeze-pr-auto-triage-line-91)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
 class="breeze-pr-auto-triage-r1" x="1464" y="2240.4" textLength="12.2" 
clip-path="url(#breeze-pr-auto-triage-line-91)">
+</text><text class="breeze-pr-auto-triage-r5" x="0" y="2264.8" 
textLength="24.4" 
clip-path="url(#breeze-pr-auto-triage-line-92)">╭─</text><text 
class="breeze-pr-auto-triage-r5" x="24.4" y="2264.8" textLength="195.2" 
clip-path="url(#breeze-pr-auto-triage-line-92)">&#160;Common&#160;options&#160;</text><text
 class="breeze-pr-auto-triage-r5" x="219.6" y="2264.8" textLength="1220" 
clip-path="url(#breeze-pr-auto-triage-line-92)">────────────────────────────────────────────────────────────────
 [...]
+</text><text class="breeze-pr-auto-triage-r5" x="0" y="2289.2" 
textLength="12.2" clip-path="url(#breeze-pr-auto-triage-line-93)">│</text><text 
class="breeze-pr-auto-triage-r4" x="24.4" y="2289.2" textLength="109.8" 
clip-path="url(#breeze-pr-auto-triage-line-93)">--dry-run</text><text 
class="breeze-pr-auto-triage-r7" x="158.6" y="2289.2" textLength="24.4" 
clip-path="url(#breeze-pr-auto-triage-line-93)">-D</text><text 
class="breeze-pr-auto-triage-r1" x="207.4" y="2289.2" textLength="719.8" [...]
+</text><text class="breeze-pr-auto-triage-r5" x="0" y="2313.6" 
textLength="12.2" clip-path="url(#breeze-pr-auto-triage-line-94)">│</text><text 
class="breeze-pr-auto-triage-r4" x="24.4" y="2313.6" textLength="109.8" 
clip-path="url(#breeze-pr-auto-triage-line-94)">--verbose</text><text 
class="breeze-pr-auto-triage-r7" x="158.6" y="2313.6" textLength="24.4" 
clip-path="url(#breeze-pr-auto-triage-line-94)">-v</text><text 
class="breeze-pr-auto-triage-r1" x="207.4" y="2313.6" textLength="585.6" [...]
+</text><text class="breeze-pr-auto-triage-r5" x="0" y="2338" textLength="12.2" 
clip-path="url(#breeze-pr-auto-triage-line-95)">│</text><text 
class="breeze-pr-auto-triage-r4" x="24.4" y="2338" textLength="109.8" 
clip-path="url(#breeze-pr-auto-triage-line-95)">--help&#160;&#160;&#160;</text><text
 class="breeze-pr-auto-triage-r7" x="158.6" y="2338" textLength="24.4" 
clip-path="url(#breeze-pr-auto-triage-line-95)">-h</text><text 
class="breeze-pr-auto-triage-r1" x="207.4" y="2338" textLength= [...]
+</text><text class="breeze-pr-auto-triage-r5" x="0" y="2362.4" 
textLength="1464" 
clip-path="url(#breeze-pr-auto-triage-line-96)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
 class="breeze-pr-auto-triage-r1" x="1464" y="2362.4" textLength="12.2" 
clip-path="url(#breeze-pr-auto-triage-line-96)">
 </text>
     </g>
     </g>
diff --git a/dev/breeze/doc/images/output_pr_auto-triage.txt 
b/dev/breeze/doc/images/output_pr_auto-triage.txt
index aeb76cfc324..2fb0e6f6277 100644
--- a/dev/breeze/doc/images/output_pr_auto-triage.txt
+++ b/dev/breeze/doc/images/output_pr_auto-triage.txt
@@ -1 +1 @@
-8f322c67369d5757a2b2b8df5248d02b
+15636cdbbdf6fde36f2ae6e5c43da51e
diff --git a/dev/breeze/src/airflow_breeze/commands/pr_commands.py 
b/dev/breeze/src/airflow_breeze/commands/pr_commands.py
index bdf810ab3e0..bd701d469c8 100644
--- a/dev/breeze/src/airflow_breeze/commands/pr_commands.py
+++ b/dev/breeze/src/airflow_breeze/commands/pr_commands.py
@@ -16,6 +16,7 @@
 # under the License.
 from __future__ import annotations
 
+import hashlib
 import json
 import os
 import re
@@ -83,6 +84,7 @@ from airflow_breeze.utils.pr_display import (
 from airflow_breeze.utils.pr_models import (
     CHECK_FAILURE_GRACE_PERIOD_HOURS,
     PRData,
+    ReviewDecision,
     UnresolvedThread,
 )
 from airflow_breeze.utils.recording import generating_command_images
@@ -132,7 +134,13 @@ _SUSPICIOUS_CHANGES_LABEL = "suspicious changes detected"
 _TRIAGE_COMMENT_MARKER = "Pull Request quality criteria"
 
 # GitHub accounts that should be auto-skipped during triage
-_BOT_ACCOUNT_LOGINS = {"dependabot", "dependabot[bot]", "renovate[bot]", 
"github-actions[bot]"}
+_BOT_ACCOUNT_LOGINS = {
+    "dependabot",
+    "dependabot[bot]",
+    "renovate[bot]",
+    "github-actions",
+    "github-actions[bot]",
+}
 
 # Proximity threshold for showing "nearby" existing comments (lines)
 _NEARBY_LINE_THRESHOLD = 5
@@ -210,7 +218,8 @@ def _cached_assess_pr(
     from airflow_breeze.utils.github import PRAssessment, Violation
     from airflow_breeze.utils.llm_utils import assess_pr
 
-    cached = _get_cached_assessment(github_repository, pr_number, head_sha)
+    checks_hash = hashlib.md5(check_status_summary.encode()).hexdigest()[:8] 
if check_status_summary else ""
+    cached = _get_cached_assessment(github_repository, pr_number, head_sha, 
checks_state=checks_hash)
     if cached is not None:
         violations = [
             Violation(
@@ -283,7 +292,9 @@ def _cached_assess_pr(
             "duration": result.duration,
             "attempts": result.attempts,
         }
-        _save_assessment_cache(github_repository, pr_number, head_sha, 
assessment_dict)
+        _save_assessment_cache(
+            github_repository, pr_number, head_sha, assessment_dict, 
checks_state=checks_hash
+        )
 
     return result
 
@@ -430,6 +441,9 @@ query($query: String!, $first: Int!, $after: String) {
         labels(first: 20) {
           nodes { name }
         }
+        reviews(last: 20) {
+          nodes { author { login } authorAssociation state }
+        }
         commits(last: 1) {
           nodes {
             commit {
@@ -465,6 +479,9 @@ query($owner: String!, $repo: String!, $number: Int!) {
       labels(first: 20) {
         nodes { name }
       }
+      reviews(last: 20) {
+        nodes { author { login } authorAssociation state }
+      }
       commits(last: 1) {
         nodes {
           commit {
@@ -942,6 +959,26 @@ def _extract_basic_check_info(pr_node: dict) -> tuple[str, 
str]:
     return head_sha, rollup.get("state", "UNKNOWN")
 
 
+def _extract_review_decisions(pr_node: dict) -> list[ReviewDecision]:
+    """Extract the latest review decision per reviewer from a GraphQL PR node.
+
+    Keeps only the most recent review per author (last wins).
+    Excludes COMMENTED and DISMISSED — only APPROVED and CHANGES_REQUESTED are 
meaningful.
+    """
+    reviews_nodes = (pr_node.get("reviews") or {}).get("nodes", [])
+    latest: dict[str, tuple[str, str]] = {}  # login -> (state, association)
+    for review in reviews_nodes:
+        author = (review.get("author") or {}).get("login", "")
+        state = review.get("state", "")
+        association = review.get("authorAssociation", "")
+        if author and state in ("APPROVED", "CHANGES_REQUESTED"):
+            latest[author] = (state, association)
+    return [
+        ReviewDecision(reviewer_login=login, state=state, 
reviewer_association=association)
+        for login, (state, association) in latest.items()
+    ]
+
+
 def _process_check_contexts(contexts: list[dict], total_count: int) -> 
tuple[str, list[str], bool]:
     """Process check context nodes into summary text, failed names, and 
test-check presence.
 
@@ -1289,13 +1326,21 @@ def _fetch_unresolved_comments_batch(
                 )
             pr.unresolved_threads = unresolved
 
-            # Detect collaborator engagement from reviews
+            # Detect collaborator engagement from reviews and enrich 
review_decisions
+            # with association data from latestReviews (which has 
authorAssociation).
             reviews = pr_data.get("latestReviews", {}).get("nodes", [])
+            assoc_by_login: dict[str, str] = {}
             for review in reviews:
                 assoc = review.get("authorAssociation", "NONE")
+                login = (review.get("author") or {}).get("login", "")
+                if login:
+                    assoc_by_login[login] = assoc
                 if assoc in _COLLABORATOR_ASSOCIATIONS:
                     pr.has_collaborator_review = True
-                    break
+            # Back-fill association on existing review_decisions
+            for rd in pr.review_decisions:
+                if not rd.reviewer_association and rd.reviewer_login in 
assoc_by_login:
+                    rd.reviewer_association = assoc_by_login[rd.reviewer_login]
             # Also count unresolved threads from collaborators as engagement
             if unresolved:
                 pr.has_collaborator_review = True
@@ -1388,17 +1433,18 @@ def _classify_already_triaged_prs(
     require_marker: bool = True,
     on_progress: Callable[[int, int], None] | None = None,
 ) -> dict[str, set[int]]:
-    """Classify already-triaged PRs into waiting vs responded.
+    """Classify already-triaged PRs into waiting vs responded vs stale_draft.
 
     Returns a dict with keys:
     - "waiting": PR numbers where we commented but author has not responded
     - "responded": PR numbers where author responded after our triage comment
+    - "stale_draft": PR numbers that are drafts, triaged >7 days ago, with no 
author response
 
     :param require_marker: if True, only match comments containing 
_TRIAGE_COMMENT_MARKER.
         If False, match any comment from the viewer (useful for workflow 
approval PRs
         where a rebase comment may have been posted instead of a full triage 
comment).
     """
-    result: dict[str, set[int]] = {"waiting": set(), "responded": set()}
+    result: dict[str, set[int]] = {"waiting": set(), "responded": set(), 
"stale_draft": set()}
     if not prs:
         return result
 
@@ -1497,7 +1543,20 @@ def _classify_already_triaged_prs(
                     author_responded = True
                     break
 
-            classification = "responded" if author_responded else "waiting"
+            if author_responded:
+                classification = "responded"
+            elif pr.is_draft and triage_comment_date:
+                # Check if triage comment is older than 7 days — stale draft
+                from datetime import datetime, timezone
+
+                try:
+                    triage_dt = 
datetime.fromisoformat(triage_comment_date.replace("Z", "+00:00"))
+                    days_since_triage = (datetime.now(timezone.utc) - 
triage_dt).days
+                    classification = "stale_draft" if days_since_triage >= 7 
else "waiting"
+                except (ValueError, TypeError):
+                    classification = "waiting"
+            else:
+                classification = "waiting"
             result[classification].add(pr.number)
             # Cache the classification result (keyed by head_sha — invalidated 
on new commits)
             if pr.head_sha and require_marker:
@@ -1725,6 +1784,7 @@ def _fetch_prs_graphql(
                 mergeable=node.get("mergeable", "UNKNOWN"),
                 labels=[lbl["name"] for lbl in (node.get("labels") or 
{}).get("nodes", []) if lbl],
                 unresolved_threads=[],
+                review_decisions=_extract_review_decisions(node),
             )
         )
 
@@ -1764,6 +1824,7 @@ def _fetch_single_pr_graphql(token: str, 
github_repository: str, pr_number: int)
         mergeable=node.get("mergeable", "UNKNOWN"),
         labels=[lbl["name"] for lbl in (node.get("labels") or {}).get("nodes", 
[]) if lbl],
         unresolved_threads=[],
+        review_decisions=_extract_review_decisions(node),
     )
 
 
@@ -2364,12 +2425,31 @@ def _display_pr_info_panels(
                 llm_label += f" [dim]({', '.join(timing_parts)})[/]"
         extra_info += f"\nLLM Review: {llm_label}"
 
+    # Maintainer review summary
+    review_info = ""
+    if pr.review_decisions:
+        maintainer_reviews = [
+            r for r in pr.review_decisions if r.reviewer_association in 
_COLLABORATOR_ASSOCIATIONS
+        ]
+        if maintainer_reviews:
+            approved = [r for r in maintainer_reviews if r.state == "APPROVED"]
+            changes_requested = [r for r in maintainer_reviews if r.state == 
"CHANGES_REQUESTED"]
+            review_parts: list[str] = []
+            if approved:
+                names = ", ".join(r.reviewer_login for r in approved)
+                review_parts.append(f"[green]{len(approved)} approved[/] 
({names})")
+            if changes_requested:
+                names = ", ".join(r.reviewer_login for r in changes_requested)
+                review_parts.append(f"[red]{len(changes_requested)} changes 
requested[/] ({names})")
+            review_info = f"\nMaintainer reviews: {' | '.join(review_parts)}"
+
     pr_info = (
         f"[link={pr.url}][cyan]#{pr.number}[/][/link] {pr.title}\n"
         f"Author: 
[link=https://github.com/{pr.author_login}][bold]{pr.author_login}[/][/link]  | 
 "
         f"Created: {_human_readable_age(pr.created_at)}  |  "
         f"Updated: {_human_readable_age(pr.updated_at)}"
         f"{status_info}"
+        f"{review_info}"
         f"{extra_info}"
     )
     console.print(Panel(pr_info, title="Pull Request", border_style="cyan"))
@@ -2942,7 +3022,6 @@ class PRStateSnapshot:
     head_sha: str
     updated_at: str
     is_draft: bool
-    mergeable: str
 
 
 def _snapshot_pr_state(pr: PRData) -> PRStateSnapshot:
@@ -2951,7 +3030,6 @@ def _snapshot_pr_state(pr: PRData) -> PRStateSnapshot:
         head_sha=pr.head_sha,
         updated_at=pr.updated_at,
         is_draft=pr.is_draft,
-        mergeable=pr.mergeable,
     )
 
 
@@ -2982,8 +3060,6 @@ def _refresh_pr_if_stale(
             changed_fields.append("PR updated since last check")
     if fresh.is_draft != snapshot.is_draft:
         changed_fields.append(f"draft status changed ({'draft' if 
fresh.is_draft else 'ready'})")
-    if fresh.mergeable != snapshot.mergeable:
-        changed_fields.append(f"merge status changed ({snapshot.mergeable} → 
{fresh.mergeable})")
 
     if not changed_fields:
         return None
@@ -3012,10 +3088,16 @@ def _refresh_pr_if_stale(
     )
 
 
-def _confirm_action(pr: PRData, description: str, forced_answer: str | None = 
None) -> bool:
+def _confirm_action(
+    pr: PRData,
+    description: str,
+    forced_answer: str | None = None,
+    stats: TriageStats | None = None,
+) -> bool:
     """Ask for final confirmation before modifying a PR. Returns True if 
confirmed.
 
     Uses single-keypress input (no Enter required) for a snappy workflow.
+    Pressing 'q' sets stats.quit_early if stats is provided and returns False.
     """
     import os
 
@@ -3027,7 +3109,7 @@ def _confirm_action(pr: PRData, description: str, 
forced_answer: str | None = No
         print(f"Forced answer for confirm '{description}': {force}")
         return force.upper() in ("Y", "YES")
 
-    prompt = f"  Confirm: {description} on PR {_pr_link(pr)}? [Y/n] "
+    prompt = f"  Confirm: {description} on PR {_pr_link(pr)}? [Y/n/q] "
     console_print(prompt, end="")
 
     try:
@@ -3046,6 +3128,11 @@ def _confirm_action(pr: PRData, description: str, 
forced_answer: str | None = No
     # Echo the character and move to next line
     console_print(ch)
 
+    if ch.upper() == "Q":
+        console_print("  [warning]Quitting...[/]")
+        if stats:
+            stats.quit_early = True
+        return False
     if ch.upper() in ("Y", "\r", "\n", ""):
         return True
     console_print(f"  [info]Cancelled — no changes made to PR 
{_pr_link(pr)}.[/]")
@@ -3087,7 +3174,7 @@ def _execute_triage_action(
             return stale_result
 
     if action == TriageAction.READY:
-        if not _confirm_action(pr, "Add 'ready for maintainer review' label", 
ctx.answer_triage):
+        if not _confirm_action(pr, "Add 'ready for maintainer review' label", 
ctx.answer_triage, stats=stats):
             stats.total_skipped_action += 1
             return None
         console_print(
@@ -3137,7 +3224,7 @@ def _execute_triage_action(
         return None
 
     if action == TriageAction.REBASE:
-        if not _confirm_action(pr, "Update (rebase) PR branch", 
ctx.answer_triage):
+        if not _confirm_action(pr, "Update (rebase) PR branch", 
ctx.answer_triage, stats=stats):
             stats.total_skipped_action += 1
             return None
 
@@ -3150,7 +3237,7 @@ def _execute_triage_action(
         return None
 
     if action == TriageAction.COMMENT:
-        if not _confirm_action(pr, "Post comment", ctx.answer_triage):
+        if not _confirm_action(pr, "Post comment", ctx.answer_triage, 
stats=stats):
             stats.total_skipped_action += 1
             return None
         text = comment_only_text or draft_comment
@@ -3163,7 +3250,7 @@ def _execute_triage_action(
         return None
 
     if action == TriageAction.DRAFT:
-        if not _confirm_action(pr, "Convert to draft and post comment", 
ctx.answer_triage):
+        if not _confirm_action(pr, "Convert to draft and post comment", 
ctx.answer_triage, stats=stats):
             stats.total_skipped_action += 1
             return None
         console_print(f"  Converting PR {_pr_link(pr)} to draft...")
@@ -3194,7 +3281,7 @@ def _execute_triage_action(
             f"@{pr.author_login}, do you believe the reviewer's concerns have 
been resolved?\n\n"
             f"If the concerns are resolved, please resolve the conversation 
threads. Thank you!"
         )
-        if not _confirm_action(pr, f"Ping reviewer(s): {mentions}", 
ctx.answer_triage):
+        if not _confirm_action(pr, f"Ping reviewer(s): {mentions}", 
ctx.answer_triage, stats=stats):
             stats.total_skipped_action += 1
             return None
         get_console().print(f"  Pinging reviewer(s) on PR {_pr_link(pr)}...")
@@ -3206,7 +3293,7 @@ def _execute_triage_action(
         return None
 
     if action == TriageAction.CLOSE:
-        if not _confirm_action(pr, "Close PR and post comment", 
ctx.answer_triage):
+        if not _confirm_action(pr, "Close PR and post comment", 
ctx.answer_triage, stats=stats):
             stats.total_skipped_action += 1
             return None
         console_print(f"  Closing PR {_pr_link(pr)}...")
@@ -3464,6 +3551,7 @@ def _display_pr_overview_table(
     pr_table.add_column("Status")
     pr_table.add_column("Title", max_width=50)
     pr_table.add_column("Author")
+    pr_table.add_column("Updated", no_wrap=True)
     pr_table.add_column("Behind", justify="right")
     pr_table.add_column("Conflicts")
     pr_table.add_column("CI Status")
@@ -3517,6 +3605,7 @@ def _display_pr_overview_table(
             overall,
             pr.title[:50],
             pr.author_login,
+            _human_readable_age(pr.updated_at),
             behind_text,
             conflicts_text,
             ci_status,
@@ -3702,17 +3791,23 @@ def _execute_tui_direct_action(
         if pending_runs:
             get_console().rule(f"[cyan]PR {_pr_link(pr)}[/]", style="dim")
             console_print(f"  [bold]{pr.title}[/] by {pr.author_login}\n")
-            confirm = _show_diff_and_confirm(
+            diff_confirm = _show_diff_and_confirm(
                 ctx.token,
                 ctx.github_repository,
                 pr,
                 forced_answer=ctx.answer_triage,
-                confirm_message="Press Enter to approve, \\[f] to flag as 
suspicious, \\[q] to quit",
+                confirm_message="Press Enter to approve, \\[s] to skip, "
+                "\\[f] to flag as suspicious, \\[q] to quit",
             )
-            if confirm == ContinueAction.QUIT:
+            if diff_confirm == ContinueAction.QUIT:
                 ctx.stats.quit_early = True
                 return
-            if confirm == ContinueAction.FLAG:
+            if diff_confirm == ContinueAction.SKIP:
+                console_print(f"  [info]Skipping PR {_pr_link(pr)} — no action 
taken.[/]")
+                entry.action_taken = "skipped"
+                if on_action:
+                    on_action(pr, "skipped")
+            elif diff_confirm == ContinueAction.FLAG:
                 console_print(f"  [bold red]Flagged PR {_pr_link(pr)} as 
suspicious — skipping approval.[/]")
                 entry.action_taken = "suspicious"
                 if on_action:
@@ -4755,10 +4850,10 @@ def _run_tui_triage(
 
         # Use timeout when background work is active so we can pick up results
         _poll_timeout = 1.0 if _bg else None
-        entry, action = tui.run_interactive(timeout=_poll_timeout)
+        tui_entry, action = tui.run_interactive(timeout=_poll_timeout)
 
         # Timeout — no input; check for background updates and re-render
-        if action is None:
+        if action is None or tui_entry is None:
             _apply_refresh_results()
             continue
 
@@ -4883,12 +4978,16 @@ def _run_tui_triage(
                         ctx.github_repository,
                         sel_pr,
                         forced_answer=ctx.answer_triage,
-                        confirm_message="Press Enter to approve, \\[f] to flag 
as suspicious, \\[q] to quit",
+                        confirm_message="Press Enter to approve, \\[s] to 
skip, "
+                        "\\[f] to flag as suspicious, \\[q] to quit",
                     )
                     if confirm == ContinueAction.QUIT:
                         ctx.stats.quit_early = True
                         break
-                    if confirm == ContinueAction.FLAG:
+                    if confirm == ContinueAction.SKIP:
+                        get_console().print(f"  [info]Skipping PR 
{_pr_link(sel_pr)} — no action taken.[/]")
+                        sel_entry.selected = False
+                    elif confirm == ContinueAction.FLAG:
                         get_console().print(f"  [bold red]Flagged PR 
{_pr_link(sel_pr)} as suspicious.[/]")
                         sel_entry.action_taken = "suspicious"
                         _cache_action(sel_pr, "suspicious")
@@ -4927,10 +5026,10 @@ def _run_tui_triage(
             _read_char()
             continue
 
-        if entry is None:
+        if tui_entry is None:
             continue
 
-        pr = entry.pr
+        pr = tui_entry.pr
 
         if action == TUIAction.OPEN:
             webbrowser.open(pr.url)
@@ -4950,7 +5049,7 @@ def _run_tui_triage(
         # Direct triage actions from TUI (without entering detailed review)
         if isinstance(action, TUIAction) and action.name.startswith("ACTION_"):
             tui.disable_mouse()
-            _execute_tui_direct_action(ctx, tui, entry, action, 
assessment_map, on_action=_cache_action)
+            _execute_tui_direct_action(ctx, tui, tui_entry, action, 
assessment_map, on_action=_cache_action)
             if ctx.stats.quit_early:
                 break
             continue
@@ -5043,7 +5142,18 @@ def _run_tui_triage(
                     if cur_entry.llm_status in ("in_progress", "pending"):
                         console_print("[info]LLM review is still in progress 
for this PR.[/]")
                     else:
-                        console_print("[success]This looks like a PR that is 
ready for review.[/]")
+                        has_approval = any(r.state == "APPROVED" for r in 
cur_pr.review_decisions)
+                        if has_approval:
+                            approvers = ", ".join(
+                                r.reviewer_login for r in 
cur_pr.review_decisions if r.state == "APPROVED"
+                            )
+                            console_print(
+                                f"[success]PR passes all checks. "
+                                f"Maintainer approval: {approvers} "
+                                f"— ready for re-review and merge.[/]"
+                            )
+                        else:
+                            console_print("[success]This looks like a PR that 
is ready for review.[/]")
 
                     if not ctx.dry_run:
                         if cur_entry.llm_status in ("in_progress", "pending"):
@@ -5053,6 +5163,8 @@ def _run_tui_triage(
                         if not go_back:
                             if was_skipped:
                                 passing_default = TriageAction.SKIP
+                            elif any(r.state == "APPROVED" for r in 
cur_pr.review_decisions):
+                                passing_default = TriageAction.REVIEW_MERGE
                             else:
                                 passing_default = TriageAction.READY
                         if not go_back:
@@ -5073,6 +5185,11 @@ def _run_tui_triage(
                                 ctx.stats.quit_early = True
                             elif act == TriageAction.SKIP:
                                 cur_entry.action_taken = "skipped"
+                            elif act == TriageAction.REVIEW_MERGE:
+                                # Browser already opened by 
prompt_triage_action
+                                cur_entry.action_taken = "review & merge"
+                                ctx.stats.total_ready += 1
+                                _cache_action(cur_pr, "review & merge")
                             else:
                                 stale = _execute_triage_action(
                                     ctx,
@@ -5954,9 +6071,19 @@ def _review_workflow_approval_prs(ctx: TriageContext, 
pending_approval: list[PRD
         return
 
     console_print(
-        "\n[info]Showing diffs one-by-one. Press Enter to continue, "
-        "\\[f] to flag as suspicious, \\[q] to quit.[/]\n"
+        "\n[info]Please review the diffs below for suspicious code "
+        "(e.g. credential exfiltration, CI abuse, supply-chain attacks). "
+        "Approve safe PRs, skip uncertain ones, or flag malicious ones.[/]"
     )
+    if not ctx.dry_run:
+        start_confirm = prompt_space_continue(
+            message="Press Enter to start reviewing diffs, \\[q] to quit",
+            forced_answer=ctx.answer_triage,
+        )
+        if start_confirm == ContinueAction.QUIT:
+            console_print("[warning]Quitting.[/]")
+            ctx.stats.quit_early = True
+            return
 
     # Track which PRs to approve vs flagged as suspicious
     prs_to_approve: list[tuple[PRData, list[dict]]] = []
@@ -5969,18 +6096,22 @@ def _review_workflow_approval_prs(ctx: TriageContext, 
pending_approval: list[PRD
         get_console().rule(f"[cyan]PR {_pr_link(pr)}[/]", style="dim")
         console_print(f"  [bold]{pr.title}[/] by {pr.author_login}\n")
 
-        action = _show_diff_and_confirm(
+        diff_action = _show_diff_and_confirm(
             ctx.token,
             ctx.github_repository,
             pr,
             forced_answer=ctx.answer_triage,
-            confirm_message="Press Enter to approve, \\[f] to flag as 
suspicious, \\[q] to quit",
+            confirm_message="Press Enter to approve, \\[s] to skip, \\[f] to 
flag as suspicious, \\[q] to quit",
         )
-        if action == ContinueAction.QUIT:
+        if diff_action == ContinueAction.QUIT:
             console_print("[warning]Quitting.[/]")
             ctx.stats.quit_early = True
             break
-        if action == ContinueAction.FLAG:
+        if diff_action == ContinueAction.SKIP:
+            console_print(f"  [info]Skipping PR {_pr_link(pr)} — no action 
taken.[/]")
+            ctx.stats.total_skipped_action += 1
+            continue
+        if diff_action == ContinueAction.FLAG:
             console_print(f"  [bold red]Flagged PR {_pr_link(pr)} by 
{pr.author_login} as suspicious.[/]")
             flagged_suspicious.append(pr)
         else:
@@ -6289,7 +6420,19 @@ def _review_passing_prs(ctx: TriageContext, passing_prs: 
list[PRData]) -> None:
         author_profile = _fetch_author_profile(ctx.token, pr.author_login, 
ctx.github_repository)
         _llm_st = "passed" if pr.number in {p.number for p in ctx.llm_passing} 
else ""
         _display_pr_info_panels(pr, author_profile, classification="All 
passed", llm_status=_llm_st)
-        console_print("[success]This looks like a PR that is ready for 
review.[/]")
+
+        # Check if a maintainer has already approved this PR
+        has_maintainer_approval = any(r.state == "APPROVED" for r in 
pr.review_decisions)
+        if has_maintainer_approval:
+            approvers = ", ".join(r.reviewer_login for r in 
pr.review_decisions if r.state == "APPROVED")
+            console_print(
+                f"[success]This PR passes all checks and has maintainer 
approval ({approvers}) "
+                f"— ready for re-review and merge.[/]"
+            )
+            default_action = TriageAction.REVIEW_MERGE
+        else:
+            console_print("[success]This looks like a PR that is ready for 
review.[/]")
+            default_action = TriageAction.READY
 
         if ctx.dry_run:
             console_print("[warning]Dry run — skipping.[/]")
@@ -6297,7 +6440,7 @@ def _review_passing_prs(ctx: TriageContext, passing_prs: 
list[PRData]) -> None:
 
         action = prompt_triage_action(
             f"Action for PR {_pr_link(pr)}?",
-            default=TriageAction.READY,
+            default=default_action,
             forced_answer=ctx.answer_triage,
             exclude={TriageAction.DRAFT} if pr.is_draft else None,
             pr_url=pr.url,
@@ -6311,12 +6454,20 @@ def _review_passing_prs(ctx: TriageContext, 
passing_prs: list[PRData]) -> None:
             ctx.stats.quit_early = True
             return
 
-        if action == TriageAction.READY:
+        if action == TriageAction.REVIEW_MERGE:
+            # The browser was already opened by prompt_triage_action — just 
label it ready
+            console_print(f"  [info]Adding '{_READY_FOR_REVIEW_LABEL}' label 
to PR {_pr_link(pr)}.[/]")
+            if _add_label(ctx.token, ctx.github_repository, pr.node_id, 
_READY_FOR_REVIEW_LABEL):
+                console_print(f"  [success]Label '{_READY_FOR_REVIEW_LABEL}' 
added to PR {_pr_link(pr)}.[/]")
+                ctx.stats.total_ready += 1
+            else:
+                console_print(f"  [warning]Failed to add label to PR 
{_pr_link(pr)}.[/]")
+        elif action == TriageAction.READY:
             if pr.is_draft:
                 confirm_desc = "Mark as ready for review (undraft + label + 
comment)"
             else:
                 confirm_desc = "Add 'ready for maintainer review' label"
-            if _confirm_action(pr, confirm_desc, ctx.answer_triage):
+            if _confirm_action(pr, confirm_desc, ctx.answer_triage, 
stats=ctx.stats):
                 if pr.is_draft:
                     # Undraft the PR first
                     if _mark_pr_ready_for_review(ctx.token, pr.node_id):
@@ -6440,7 +6591,7 @@ def _review_stale_review_requests(
             return
 
         if action == TriageAction.COMMENT:
-            if _confirm_action(pr, "Post review nudge comment", 
ctx.answer_triage):
+            if _confirm_action(pr, "Post review nudge comment", 
ctx.answer_triage, stats=ctx.stats):
                 if _post_comment(ctx.token, pr.node_id, comment):
                     console_print(f"  [success]Comment posted on PR 
{_pr_link(pr)}.[/]")
                     ctx.stats.total_review_nudges += 1
@@ -6449,7 +6600,9 @@ def _review_stale_review_requests(
             else:
                 ctx.stats.total_skipped_action += 1
         elif action == TriageAction.READY:
-            if _confirm_action(pr, "Add 'ready for maintainer review' label", 
ctx.answer_triage):
+            if _confirm_action(
+                pr, "Add 'ready for maintainer review' label", 
ctx.answer_triage, stats=ctx.stats
+            ):
                 if _add_label(ctx.token, ctx.github_repository, pr.node_id, 
_READY_FOR_REVIEW_LABEL):
                     console_print(
                         f"  [success]Label '{_READY_FOR_REVIEW_LABEL}' added 
to PR {_pr_link(pr)}.[/]"
@@ -6537,6 +6690,197 @@ def _review_already_triaged_prs(
             console_print(f"[dim]PR #{pr.number} — {det.category.replace('_', 
' ')}.[/]")
 
 
+def _has_maintainer_approval(pr: PRData) -> bool:
+    """Check if any maintainer (COLLABORATOR/MEMBER/OWNER) has approved the 
PR."""
+    return any(
+        r.state == "APPROVED" and r.reviewer_association in 
_COLLABORATOR_ASSOCIATIONS
+        for r in pr.review_decisions
+    )
+
+
+def _process_stale_pr(
+    ctx: TriageContext,
+    pr: PRData,
+    *,
+    default_action: TriageAction,
+    staleness_reason: str,
+    classification: str,
+) -> None:
+    """Process a single stale PR: assess, show details, and execute action.
+
+    Runs deterministic checks to include issue summary in the comment.
+    If the PR has maintainer approval and no deterministic issues, offers
+    the option to mark as ready for review instead.
+    """
+    author_profile = _fetch_author_profile(ctx.token, pr.author_login, 
ctx.github_repository)
+
+    # Run deterministic assessment to find issues
+    det = _assess_pr_deterministic(pr, token=ctx.token, 
github_repository=ctx.github_repository)
+    has_issues = det.category == "flagged" and det.assessment and 
det.assessment.violations
+    has_approval = _has_maintainer_approval(pr)
+
+    _display_pr_info_panels(pr, author_profile, compact=True, 
classification=classification)
+
+    # If maintainer approved and no deterministic issues, offer to mark ready
+    if has_approval and not has_issues:
+        approvers = ", ".join(
+            r.reviewer_login
+            for r in pr.review_decisions
+            if r.state == "APPROVED" and r.reviewer_association in 
_COLLABORATOR_ASSOCIATIONS
+        )
+        console_print(f"  [green]Maintainer approval from {approvers} — no 
deterministic issues found.[/]")
+        console_print(f"  [info]Reason for staleness: {staleness_reason}[/]")
+        action = prompt_triage_action(
+            f"Action for PR {_pr_link(pr)}",
+            default=TriageAction.READY,
+            forced_answer=ctx.answer_triage,
+            exclude={TriageAction.RERUN, TriageAction.REBASE, 
TriageAction.PING},
+        )
+        if action == TriageAction.QUIT:
+            ctx.stats.quit_early = True
+            return
+        if action == TriageAction.SKIP:
+            return
+        if action == TriageAction.READY:
+            _execute_triage_action(ctx, pr, TriageAction.READY, 
draft_comment="", close_comment="")
+            return
+        # Fall through to draft/close with violations below
+
+    # Build comment with issue summary if violations found
+    is_collab = pr.author_association in _COLLABORATOR_ASSOCIATIONS
+    if has_issues and det.assessment:
+        violations = det.assessment.violations
+        draft_comment = _build_comment(
+            pr.author_login,
+            violations,
+            pr.number,
+            pr.commits_behind,
+            pr.base_ref,
+            is_collaborator=is_collab,
+        )
+        close_comment = _build_close_comment(
+            pr.author_login,
+            violations,
+            pr.number,
+            ctx.author_flagged_count.get(pr.author_login, 0),
+            is_collaborator=is_collab,
+        )
+    else:
+        # No violations — use staleness reason as the comment
+        draft_comment = (
+            f"{staleness_reason}\n\n"
+            f"**@{pr.author_login}**, please mark this PR as ready for review 
when you are "
+            f"ready to continue working on it. Thank you for your 
contribution!\n\n"
+            f"<!-- {_TRIAGE_COMMENT_MARKER} -->"
+        )
+        close_comment = (
+            f"{staleness_reason}\n\n"
+            f"**@{pr.author_login}**, you are welcome to reopen this PR when 
you are "
+            f"ready to continue working on it. Thank you for your 
contribution!\n\n"
+            f"<!-- {_TRIAGE_COMMENT_MARKER} -->"
+        )
+
+    _execute_triage_action(
+        ctx,
+        pr,
+        default_action,
+        draft_comment=draft_comment,
+        close_comment=close_comment,
+    )
+
+
+def _draft_stale_workflow_prs(
+    ctx: TriageContext,
+    stale_workflow_prs: list[PRData],
+) -> None:
+    """Convert stale workflow-approval PRs to draft (no activity for over 4 
weeks)."""
+    if ctx.stats.quit_early or not stale_workflow_prs:
+        return
+
+    stale_workflow_prs.sort(key=lambda p: (p.author_login.lower(), p.number))
+    console_print(
+        f"\n[warning]{len(stale_workflow_prs)} stale workflow-approval "
+        f"{'PRs' if len(stale_workflow_prs) != 1 else 'PR'} "
+        f"— no activity for over 4 weeks, converting to draft:[/]\n"
+    )
+    for pr in stale_workflow_prs:
+        if ctx.stats.quit_early:
+            break
+        _process_stale_pr(
+            ctx,
+            pr,
+            default_action=TriageAction.DRAFT,
+            staleness_reason="This pull request has been awaiting workflow 
approval for over "
+            "4 weeks with no activity from the author.",
+            classification="Stale WF Approval",
+        )
+
+
+def _close_stale_draft_prs(
+    ctx: TriageContext,
+    stale_draft_prs: list[PRData],
+    triaged_stale_nums: set[int] | None = None,
+) -> None:
+    """Close stale draft PRs.
+
+    Handles both triaged drafts (triage comment >7 days, no author response) 
and
+    non-triaged drafts (no author activity for >3 weeks based on updated_at).
+    """
+    if ctx.stats.quit_early or not stale_draft_prs:
+        return
+
+    triaged_stale_nums = triaged_stale_nums or set()
+    stale_draft_prs.sort(key=lambda p: (p.author_login.lower(), p.number))
+    console_print(
+        f"\n[warning]{len(stale_draft_prs)} stale draft "
+        f"{'PRs' if len(stale_draft_prs) != 1 else 'PR'} "
+        f"— stale drafts, closing:[/]\n"
+    )
+    for pr in stale_draft_prs:
+        if ctx.stats.quit_early:
+            break
+        if pr.number in triaged_stale_nums:
+            reason = (
+                "This pull request has been converted to draft due to quality 
issues "
+                "more than a week ago and there has been no response from the 
author."
+            )
+        else:
+            reason = "This draft pull request has had no activity from the 
author for over 3 weeks."
+        _process_stale_pr(
+            ctx,
+            pr,
+            default_action=TriageAction.CLOSE,
+            staleness_reason=reason,
+            classification="Stale Draft",
+        )
+
+
+def _draft_inactive_open_prs(
+    ctx: TriageContext,
+    inactive_open_prs: list[PRData],
+) -> None:
+    """Convert inactive non-draft PRs to draft (no activity for over 4 
weeks)."""
+    if ctx.stats.quit_early or not inactive_open_prs:
+        return
+
+    inactive_open_prs.sort(key=lambda p: (p.author_login.lower(), p.number))
+    console_print(
+        f"\n[warning]{len(inactive_open_prs)} inactive open "
+        f"{'PRs' if len(inactive_open_prs) != 1 else 'PR'} "
+        f"— no activity for over 4 weeks, converting to draft:[/]\n"
+    )
+    for pr in inactive_open_prs:
+        if ctx.stats.quit_early:
+            break
+        _process_stale_pr(
+            ctx,
+            pr,
+            default_action=TriageAction.DRAFT,
+            staleness_reason="This pull request has had no activity from the 
author for over 4 weeks.",
+            classification="Inactive",
+        )
+
+
 def _display_json_fix_info(fix_info: dict) -> None:
     """Display deferred LLM JSON parse-fix debug info."""
     console = get_console()
@@ -7156,11 +7500,24 @@ def _review_ready_prs_review_mode(
                 _print_pr_header(pr, index=pass_i, total=len(det_passing))
                 author_profile = _fetch_author_profile(ctx.token, 
pr.author_login, ctx.github_repository)
                 _display_pr_info_panels(pr, author_profile, 
open_in_browser=True, classification="All passed")
-                console.print("[success]Deterministic checks pass.[/]")
+
+                has_approval = any(r.state == "APPROVED" for r in 
pr.review_decisions)
+                if has_approval:
+                    approvers = ", ".join(
+                        r.reviewer_login for r in pr.review_decisions if 
r.state == "APPROVED"
+                    )
+                    console.print(
+                        f"[success]Deterministic checks pass. "
+                        f"Maintainer approval: {approvers} — ready for 
re-review and merge.[/]"
+                    )
+                    passing_default = TriageAction.REVIEW_MERGE
+                else:
+                    console.print("[success]Deterministic checks pass.[/]")
+                    passing_default = TriageAction.SKIP
 
                 action = prompt_triage_action(
                     f"Action for PR {_pr_link(pr)}?",
-                    default=TriageAction.SKIP,
+                    default=passing_default,
                     forced_answer=ctx.answer_triage,
                     exclude={TriageAction.RERUN},
                     pr_url=pr.url,
@@ -7173,7 +7530,10 @@ def _review_ready_prs_review_mode(
                     ctx.stats.quit_early = True
                     pr_actions[pr.number] = "quit"
                     return pr_timings, pr_actions
-                if action == TriageAction.SKIP:
+                if action == TriageAction.REVIEW_MERGE:
+                    pr_actions[pr.number] = "review & merge"
+                    ctx.stats.total_ready += 1
+                elif action == TriageAction.SKIP:
                     ctx.stats.total_skipped_action += 1
                     pr_actions[pr.number] = "skipped"
                 else:
@@ -8410,9 +8770,19 @@ def _clear_triage_caches(github_repository: str) -> None:
         if cache_dir.exists():
             count = len(list(cache_dir.glob("*.json")))
             shutil.rmtree(cache_dir)
-            console.print(f"[info]Cleared LLM {label} cache ({count} entries) 
at {cache_dir}.[/]")
+            console.print(f"[info]Cleared {label} cache ({count} entries) at 
{cache_dir}.[/]")
         else:
-            console.print(f"[info]LLM {label} cache is already empty.[/]")
+            console.print(f"[info]{label.capitalize()} cache is already 
empty.[/]")
+
+    # Collaborators cache is a single file outside the CacheStore directories
+    collab_path = _get_collaborators_cache_path(github_repository)
+    if collab_path.exists():
+        collab_path.unlink()
+        console.print(f"[info]Cleared collaborators cache at 
{collab_path}.[/]")
+
+    # Clear in-memory caches
+    _author_profile_cache.clear()
+    _label_id_cache.clear()
 
 
 def _validate_and_refresh_caches(
@@ -9019,7 +9389,22 @@ def _launch_llm_and_build_context(
     return ctx, llm_executor
 
 
-def _process_pagination_batch(
+@dataclass
+class _PrefetchedBatch:
+    """Pre-fetched and enriched next-page data, ready for interactive 
review."""
+
+    all_prs: list[PRData]
+    has_next_page: bool
+    next_cursor: str | None
+    batch_result: BatchEnrichResult
+    candidate_prs: list[PRData]
+    batch_assessments: dict[int, PRAssessment]
+    batch_llm_candidates: list[PRData]
+    batch_pending: list[PRData]
+    batch_passing: list[PRData]
+
+
+def _prefetch_next_batch(
     *,
     token: str,
     github_repository: str,
@@ -9043,16 +9428,12 @@ def _process_pagination_batch(
     max_num: int,
     viewer_login: str,
     run_api: bool,
-    run_llm: bool,
-    llm_model: str,
-    llm_concurrency: int,
-    dry_run: bool,
-    answer_triage: str | None,
-    main_failures: RecentPRFailureInfo | None,
-    stats: TriageStats,
-    accepted_prs: list[PRData],
-) -> tuple[bool, str | None]:
-    """Process one pagination batch: fetch, enrich, categorize, review. 
Returns (has_next, cursor)."""
+) -> _PrefetchedBatch | None:
+    """Fetch and enrich the next page of PRs in the background (no interactive 
review).
+
+    Returns a _PrefetchedBatch with all data needed to start the interactive 
review,
+    or None if there are no more PRs.
+    """
     all_prs, has_next_page, new_cursor, _ = _fetch_prs_graphql(
         token,
         github_repository,
@@ -9067,10 +9448,10 @@ def _process_pagination_batch(
         updated_before=updated_before,
         review_requested=review_requested,
         after_cursor=next_cursor,
+        quiet=True,
     )
     if not all_prs:
-        console_print("[info]No more PRs to process.[/]")
-        return False, new_cursor
+        return None
 
     batch_result = _enrich_and_filter_batch(
         token,
@@ -9084,76 +9465,259 @@ def _process_pagination_batch(
         min_commits_behind=min_commits_behind,
         max_num=max_num,
         viewer_login=viewer_login,
+        quiet=True,
     )
     all_prs = batch_result.all_prs
     candidate_prs = batch_result.candidate_prs
-    accepted_prs.extend(batch_result.accepted_prs)
 
-    # Reclassification logging
-    non_collab_success = [
-        pr
-        for pr in all_prs
-        if pr.checks_state == "SUCCESS"
-        and pr.author_association not in _COLLABORATOR_ASSOCIATIONS
-        and not _is_bot_account(pr.author_login)
-    ]
-    if non_collab_success:
-        reclassified = sum(1 for pr in non_collab_success if pr.checks_state 
== "NOT_RUN")
-        if reclassified:
-            console_print(
-                f"  [warning]{reclassified} {'PRs' if reclassified != 1 else 
'PR'} "
-                f"reclassified to NOT_RUN (only bot/labeler checks).[/]"
-            )
-
-    if not candidate_prs:
-        console_print("[info]No PRs to assess in this batch.[/]")
-        _display_pr_overview_table(all_prs)
-        return has_next_page, new_cursor
-
-    _display_pr_overview_table(
-        all_prs,
-        triaged_waiting_nums=batch_result.triaged_classification["waiting"],
-        
triaged_responded_nums=batch_result.triaged_classification["responded"],
-    )
-
-    if not candidate_prs:
-        console_print("[info]All PRs in this batch already triaged.[/]")
-        return has_next_page, new_cursor
-
-    # Enrich and assess
-    _enrich_candidate_details(token, github_repository, candidate_prs, 
run_api=run_api)
+    # Enrich candidates with check details and run deterministic assessment
+    if candidate_prs:
+        _enrich_candidate_details(token, github_repository, candidate_prs, 
run_api=run_api, quiet=True)
 
+    batch_assessments: dict[int, PRAssessment] = {}
+    batch_llm_candidates: list[PRData] = []
+    batch_pending: list[PRData] = []
     batch_passing: list[PRData] = []
+
     if run_api:
-        batch_assessments: dict[int, PRAssessment] = {}
-        batch_llm_candidates: list[PRData] = []
-        batch_pending: list[PRData] = []
         for pr in candidate_prs:
             det = _assess_pr_deterministic(pr, token=token, 
github_repository=github_repository)
             if det.category == "flagged" and det.assessment:
                 batch_assessments[pr.number] = det.assessment
-            elif det.category == "grace_period":
-                get_console().print(
-                    f"  [dim]Skipped {_pr_link(pr)} — CI failures less than "
-                    f"{pr.ci_grace_period_hours}h old"
-                    f"{' (collaborator engaged)' if pr.has_collaborator_review 
else ''}[/]"
-                )
-            elif det.category in ("in_progress", "draft_skipped"):
+            elif det.category in ("in_progress", "draft_skipped", 
"grace_period"):
                 pass
             elif det.category == "pending_approval":
                 batch_pending.append(pr)
             elif det.category == "llm_candidate":
                 batch_llm_candidates.append(pr)
     else:
-        batch_assessments = {}
-        batch_llm_candidates = []
-        batch_pending = []
         for pr in candidate_prs:
             if pr.checks_state == "NOT_RUN":
                 batch_pending.append(pr)
             else:
                 batch_llm_candidates.append(pr)
 
+    return _PrefetchedBatch(
+        all_prs=all_prs,
+        has_next_page=has_next_page,
+        next_cursor=new_cursor,
+        batch_result=batch_result,
+        candidate_prs=candidate_prs,
+        batch_assessments=batch_assessments,
+        batch_llm_candidates=batch_llm_candidates,
+        batch_pending=batch_pending,
+        batch_passing=batch_passing,
+    )
+
+
+class _SequentialPrefetcher:
+    """Manages background prefetching of the next page during sequential 
review.
+
+    Call ``maybe_start()`` between review phases — it triggers the prefetch 
once
+    the number of reviewed items crosses 75 % of the batch total.
+    Call ``get_result()`` to block until the prefetch completes and retrieve 
the data.
+    """
+
+    def __init__(
+        self,
+        *,
+        total_items: int,
+        has_next_page: bool,
+        fetch_kwargs: dict,
+    ):
+        self._threshold = int(total_items * 0.75)
+        self._has_next_page = has_next_page
+        self._fetch_kwargs = fetch_kwargs
+        self._reviewed = 0
+        self._future: Future | None = None
+        self._executor: ThreadPoolExecutor | None = None
+
+    @property
+    def started(self) -> bool:
+        return self._future is not None
+
+    def maybe_start(self, reviewed_so_far: int) -> None:
+        """Trigger the background prefetch if we've crossed the 75 % 
threshold."""
+        self._reviewed = reviewed_so_far
+        if self._future is not None or not self._has_next_page:
+            return
+        if self._reviewed >= self._threshold:
+            self._executor = ThreadPoolExecutor(max_workers=1)
+            self._future = self._executor.submit(_prefetch_next_batch, 
**self._fetch_kwargs)
+
+    def get_result(self) -> _PrefetchedBatch | None:
+        """Block until the prefetch is done and return the result (or None)."""
+        if self._future is None:
+            return None
+        try:
+            return self._future.result(timeout=120)
+        except Exception:
+            return None
+        finally:
+            if self._executor:
+                self._executor.shutdown(wait=False)
+                self._executor = None
+
+    def shutdown(self) -> None:
+        if self._executor:
+            self._executor.shutdown(wait=False, cancel_futures=True)
+            self._executor = None
+
+
+def _process_pagination_batch(
+    *,
+    token: str,
+    github_repository: str,
+    exact_labels: tuple[str, ...],
+    exact_exclude_labels: tuple[str, ...],
+    filter_user: str | None,
+    sort: str,
+    batch_size: int,
+    created_after: str | None,
+    created_before: str | None,
+    updated_after: str | None,
+    updated_before: str | None,
+    review_requested: str | None,
+    next_cursor: str | None,
+    wildcard_labels: list[str],
+    wildcard_exclude_labels: list[str],
+    author_filter: AuthorFilter,
+    include_drafts: bool,
+    checks_state: str,
+    min_commits_behind: int,
+    max_num: int,
+    viewer_login: str,
+    run_api: bool,
+    run_llm: bool,
+    llm_model: str,
+    llm_concurrency: int,
+    dry_run: bool,
+    answer_triage: str | None,
+    main_failures: RecentPRFailureInfo | None,
+    stats: TriageStats,
+    accepted_prs: list[PRData],
+    prefetched: _PrefetchedBatch | None = None,
+) -> tuple[bool, str | None]:
+    """Process one pagination batch: fetch, enrich, categorize, review. 
Returns (has_next, cursor).
+
+    If ``prefetched`` is provided, the fetch/enrich/assess steps are skipped 
and the
+    pre-computed data is used directly. This happens when the previous batch 
triggered
+    a background prefetch via ``_SequentialPrefetcher``.
+    """
+    if prefetched is not None:
+        # Use pre-fetched data
+        all_prs = prefetched.all_prs
+        has_next_page = prefetched.has_next_page
+        new_cursor = prefetched.next_cursor
+        batch_result = prefetched.batch_result
+        candidate_prs = prefetched.candidate_prs
+        accepted_prs.extend(batch_result.accepted_prs)
+        batch_assessments = prefetched.batch_assessments
+        batch_llm_candidates = prefetched.batch_llm_candidates
+        batch_pending = prefetched.batch_pending
+        batch_passing = list(prefetched.batch_passing)
+    else:
+        all_prs, has_next_page, new_cursor, _ = _fetch_prs_graphql(
+            token,
+            github_repository,
+            labels=exact_labels,
+            exclude_labels=exact_exclude_labels,
+            filter_user=filter_user,
+            sort=sort,
+            batch_size=batch_size,
+            created_after=created_after,
+            created_before=created_before,
+            updated_after=updated_after,
+            updated_before=updated_before,
+            review_requested=review_requested,
+            after_cursor=next_cursor,
+        )
+        if not all_prs:
+            console_print("[info]No more PRs to process.[/]")
+            return False, new_cursor
+
+        batch_result = _enrich_and_filter_batch(
+            token,
+            github_repository,
+            all_prs,
+            wildcard_labels=wildcard_labels,
+            wildcard_exclude_labels=wildcard_exclude_labels,
+            author_filter=author_filter,
+            include_drafts=include_drafts,
+            checks_state=checks_state,
+            min_commits_behind=min_commits_behind,
+            max_num=max_num,
+            viewer_login=viewer_login,
+        )
+        all_prs = batch_result.all_prs
+        candidate_prs = batch_result.candidate_prs
+        accepted_prs.extend(batch_result.accepted_prs)
+
+        batch_passing = []
+        batch_assessments = {}
+        batch_llm_candidates = []
+        batch_pending = []
+
+    if prefetched is None:
+        # Reclassification logging
+        non_collab_success = [
+            pr
+            for pr in all_prs
+            if pr.checks_state == "SUCCESS"
+            and pr.author_association not in _COLLABORATOR_ASSOCIATIONS
+            and not _is_bot_account(pr.author_login)
+        ]
+        if non_collab_success:
+            reclassified = sum(1 for pr in non_collab_success if 
pr.checks_state == "NOT_RUN")
+            if reclassified:
+                console_print(
+                    f"  [warning]{reclassified} {'PRs' if reclassified != 1 
else 'PR'} "
+                    f"reclassified to NOT_RUN (only bot/labeler checks).[/]"
+                )
+
+    if not candidate_prs:
+        console_print("[info]No PRs to assess in this batch.[/]")
+        _display_pr_overview_table(all_prs)
+        return has_next_page, new_cursor
+
+    _display_pr_overview_table(
+        all_prs,
+        triaged_waiting_nums=batch_result.triaged_classification["waiting"],
+        
triaged_responded_nums=batch_result.triaged_classification["responded"],
+    )
+
+    if not candidate_prs:
+        console_print("[info]All PRs in this batch already triaged.[/]")
+        return has_next_page, new_cursor
+
+    if prefetched is None:
+        # Enrich and assess
+        _enrich_candidate_details(token, github_repository, candidate_prs, 
run_api=run_api)
+
+        if run_api:
+            for pr in candidate_prs:
+                det = _assess_pr_deterministic(pr, token=token, 
github_repository=github_repository)
+                if det.category == "flagged" and det.assessment:
+                    batch_assessments[pr.number] = det.assessment
+                elif det.category == "grace_period":
+                    get_console().print(
+                        f"  [dim]Skipped {_pr_link(pr)} — CI failures less 
than "
+                        f"{pr.ci_grace_period_hours}h old"
+                        f"{' (collaborator engaged)' if 
pr.has_collaborator_review else ''}[/]"
+                    )
+                elif det.category in ("in_progress", "draft_skipped"):
+                    pass
+                elif det.category == "pending_approval":
+                    batch_pending.append(pr)
+                elif det.category == "llm_candidate":
+                    batch_llm_candidates.append(pr)
+        else:
+            for pr in candidate_prs:
+                if pr.checks_state == "NOT_RUN":
+                    batch_pending.append(pr)
+                else:
+                    batch_llm_candidates.append(pr)
+
     if not run_llm:
         batch_passing.extend(batch_llm_candidates)
         batch_llm_candidates_for_llm: list[PRData] = []
@@ -9516,8 +10080,11 @@ class StartupEnrichResult:
     already_triaged: list[PRData]
     already_triaged_nums: set[int]
     triaged_classification: dict[str, set[int]]
+    stale_draft_prs: list[PRData]
+    inactive_open_prs: list[PRData]
     triaged_waiting_count: int
     triaged_responded_count: int
+    triaged_stale_draft_count: int
     total_skipped_collaborator: int
     total_skipped_bot: int
     total_skipped_accepted: int
@@ -9668,23 +10235,84 @@ def _run_startup_enrichment(
     triaged_classification = _classify_already_triaged_prs(
         token, github_repository, candidate_prs, viewer_login, 
on_progress=on_triage_progress
     )
-    already_triaged_nums = triaged_classification["waiting"] | 
triaged_classification["responded"]
+    stale_draft_nums = triaged_classification["stale_draft"]
+    already_triaged_nums = (
+        triaged_classification["waiting"] | 
triaged_classification["responded"] | stale_draft_nums
+    )
     triaged_waiting_count = len(triaged_classification["waiting"])
     triaged_responded_count = len(triaged_classification["responded"])
+    triaged_stale_draft_count = len(stale_draft_nums)
+    stale_draft_prs: list[PRData] = []
     already_triaged: list[PRData] = []
     if already_triaged_nums:
-        already_triaged = [pr for pr in candidate_prs if pr.number in 
already_triaged_nums]
+        stale_draft_prs = [pr for pr in candidate_prs if pr.number in 
stale_draft_nums]
+        already_triaged = [
+            pr
+            for pr in candidate_prs
+            if pr.number in already_triaged_nums and pr.number not in 
stale_draft_nums
+        ]
         candidate_prs = [pr for pr in candidate_prs if pr.number not in 
already_triaged_nums]
-        if not quiet:
+
+    # Check non-triaged draft PRs: if updated_at is >3 weeks old, treat as 
stale and close.
+    # Otherwise, skip them from triage (they stay in draft until stale).
+    # Also check non-draft PRs: if updated_at is >4 weeks old, propose closing.
+    from datetime import datetime, timedelta, timezone
+
+    _now = datetime.now(timezone.utc)
+    _draft_stale_cutoff = _now - timedelta(weeks=3)
+    _open_inactive_cutoff = _now - timedelta(weeks=4)
+    non_stale_draft_count = 0
+    inactive_open_prs: list[PRData] = []
+    for pr_list in (candidate_prs, already_triaged):
+        kept: list[PRData] = []
+        for pr in pr_list:
+            if pr.is_draft:
+                # Check if this non-triaged draft is stale based on updated_at
+                try:
+                    updated_dt = 
datetime.fromisoformat(pr.updated_at.replace("Z", "+00:00"))
+                    if updated_dt < _draft_stale_cutoff:
+                        stale_draft_prs.append(pr)
+                    else:
+                        non_stale_draft_count += 1
+                except (ValueError, TypeError):
+                    non_stale_draft_count += 1
+                continue
+            # Non-draft: check if inactive for >4 weeks
+            try:
+                updated_dt = datetime.fromisoformat(pr.updated_at.replace("Z", 
"+00:00"))
+                if updated_dt < _open_inactive_cutoff:
+                    inactive_open_prs.append(pr)
+                    continue
+            except (ValueError, TypeError):
+                pass
+            kept.append(pr)
+        pr_list[:] = kept
+
+    if not quiet:
+        has_skipped = already_triaged_nums or non_stale_draft_count or 
stale_draft_prs or inactive_open_prs
+        if has_skipped:
+            parts = []
+            if triaged_waiting_count:
+                parts.append(f"{triaged_waiting_count} commented")
+            if triaged_responded_count:
+                parts.append(f"{triaged_responded_count} author responded")
+            if stale_draft_prs:
+                parts.append(f"{len(stale_draft_prs)} stale drafts to close")
+            if inactive_open_prs:
+                parts.append(f"{len(inactive_open_prs)} inactive open PRs to 
draft")
+            if non_stale_draft_count:
+                parts.append(f"{non_stale_draft_count} non-stale drafts 
skipped")
+            total_skipped = (
+                len(already_triaged) + len(stale_draft_prs) + 
len(inactive_open_prs) + non_stale_draft_count
+            )
             console_print(
-                f"[info]Skipped {len(already_triaged)} already-triaged "
-                f"{'PRs' if len(already_triaged) != 1 else 'PR'} "
-                f"({triaged_waiting_count} commented, "
-                f"{triaged_responded_count} author responded) "
+                f"[info]Skipped/classified {total_skipped} "
+                f"{'PRs' if total_skipped != 1 else 'PR'} "
+                f"({', '.join(parts)}) "
                 f"({_fmt_duration(time.monotonic() - t_step)})[/]"
             )
-    elif not quiet:
-        console_print(f"  [dim]None found ({_fmt_duration(time.monotonic() - 
t_step)})[/]")
+        else:
+            console_print(f"  [dim]None found ({_fmt_duration(time.monotonic() 
- t_step)})[/]")
 
     # Overview table
     if not quiet:
@@ -9699,9 +10327,12 @@ def _run_startup_enrichment(
         accepted_prs=accepted_prs,
         already_triaged=already_triaged,
         already_triaged_nums=already_triaged_nums,
+        stale_draft_prs=stale_draft_prs,
+        inactive_open_prs=inactive_open_prs,
         triaged_classification=triaged_classification,
         triaged_waiting_count=triaged_waiting_count,
         triaged_responded_count=triaged_responded_count,
+        triaged_stale_draft_count=triaged_stale_draft_count,
         total_skipped_collaborator=total_skipped_collaborator,
         total_skipped_bot=total_skipped_bot,
         total_skipped_accepted=total_skipped_accepted,
@@ -9845,7 +10476,6 @@ def _build_selection_criteria(
     updated_before: str | None,
     checks_state: str,
     min_commits_behind: int,
-    include_drafts: bool,
     author_filter: AuthorFilter,
     sort: str,
     batch_size: int,
@@ -9875,8 +10505,6 @@ def _build_selection_criteria(
         parts.append(f"checks={checks_state}")
     if min_commits_behind > 0:
         parts.append(f"behind>={min_commits_behind}")
-    if include_drafts:
-        parts.append("include_drafts")
     if author_filter != AuthorFilter.CONTRIBUTORS:
         parts.append(f"authors={author_filter.value}")
     if sort != "created":
@@ -10056,12 +10684,6 @@ def _display_review_mode_summary(
     default=None,
     help="Only PRs updated on or before this date (YYYY-MM-DD).",
 )
[email protected](
-    "--include-drafts",
-    is_flag=True,
-    default=False,
-    help="Include draft PRs in triage (normally skipped). Passing drafts can 
be marked as ready for review.",
-)
 @click.option(
     "--pending-approval-only",
     is_flag=True,
@@ -10116,7 +10738,7 @@ def _display_review_mode_summary(
 @click.option(
     "--sort",
     type=click.Choice(["created-asc", "created-desc", "updated-asc", 
"updated-desc"]),
-    default="created-desc",
+    default="updated-asc",
     show_default=True,
     help="Sort order for PR search results.",
 )
@@ -10167,7 +10789,6 @@ def auto_triage(
     updated_after: str | None,
     updated_before: str | None,
     authors: str,
-    include_drafts: bool,
     pending_approval_only: bool,
     triage_mode: str,
     tui: bool,
@@ -10189,6 +10810,9 @@ def auto_triage(
     )
 
     author_filter = AuthorFilter(authors)
+    # Draft PRs are always included — they go through the same triage pipeline.
+    # Stale drafts (triaged >7 days or inactive >3 weeks) are auto-closed.
+    include_drafts = True
 
     token = _resolve_github_token(github_token)
     if not token:
@@ -10440,6 +11064,8 @@ def auto_triage(
     accepted_prs = enrich_result.accepted_prs
     already_triaged = enrich_result.already_triaged
     already_triaged_nums = enrich_result.already_triaged_nums
+    stale_draft_prs = enrich_result.stale_draft_prs
+    inactive_open_prs = enrich_result.inactive_open_prs
     total_skipped_collaborator = enrich_result.total_skipped_collaborator
     total_skipped_bot = enrich_result.total_skipped_bot
     total_skipped_accepted = enrich_result.total_skipped_accepted
@@ -10462,7 +11088,14 @@ def auto_triage(
     )
     _step_done("verify_ci", f"{reclassified} reclassified" if reclassified 
else "none to verify")
     _step_done("classify", f"{len(candidate_prs)} candidates")
-    triage_status = f"{len(already_triaged)} skipped" if already_triaged_nums 
else "none found"
+    triage_parts = []
+    if already_triaged:
+        triage_parts.append(f"{len(already_triaged)} skipped")
+    if stale_draft_prs:
+        triage_parts.append(f"{len(stale_draft_prs)} stale drafts")
+    if inactive_open_prs:
+        triage_parts.append(f"{len(inactive_open_prs)} inactive")
+    triage_status = ", ".join(triage_parts) if triage_parts else "none found"
     _step_done("triage_check", triage_status)
 
     t_phase1_end = time.monotonic()
@@ -10624,6 +11257,29 @@ def auto_triage(
                     f"already commented on (no new commits since).[/]"
                 )
 
+    # Split out workflow-approval PRs that have been inactive for >4 weeks — 
close instead of approve
+    from datetime import datetime, timedelta, timezone
+
+    _wf_stale_cutoff = datetime.now(timezone.utc) - timedelta(weeks=4)
+    stale_workflow_prs: list[PRData] = []
+    active_pending: list[PRData] = []
+    for pr in pending_approval:
+        try:
+            updated_dt = datetime.fromisoformat(pr.updated_at.replace("Z", 
"+00:00"))
+            if updated_dt < _wf_stale_cutoff:
+                stale_workflow_prs.append(pr)
+            else:
+                active_pending.append(pr)
+        except (ValueError, TypeError):
+            active_pending.append(pr)
+    pending_approval = active_pending
+    if stale_workflow_prs and not use_tui:
+        console_print(
+            f"[info]Found {len(stale_workflow_prs)} workflow-approval "
+            f"{'PRs' if len(stale_workflow_prs) != 1 else 'PR'} "
+            f"inactive for >4 weeks — will convert to draft.[/]"
+        )
+
     # If --pending-approval-only, discard all assessment results and only keep 
pending_approval
     if pending_approval_only:
         assessments = {}
@@ -10704,8 +11360,14 @@ def auto_triage(
     det_flagged_prs = [(pr, assessments[pr.number]) for pr in candidate_prs if 
pr.number in assessments]
     det_flagged_prs.sort(key=lambda pair: (pair[0].author_login.lower(), 
pair[0].number))
 
+    # Remove non-stale draft PRs from all_prs so they don't appear in TUI or 
summaries.
+    # Only stale drafts (in stale_draft_prs) are kept for the closing phase.
+    _stale_draft_nums = {pr.number for pr in stale_draft_prs}
+    all_prs = [pr for pr in all_prs if not pr.is_draft or pr.number in 
_stale_draft_nums]
+
     _tui_acted_entries: list[dict] = []
     _t_tui_start: float = 0.0
+    prefetcher: _SequentialPrefetcher | None = None
     try:
         if use_tui:
             # Full-screen TUI mode: show all PRs in an interactive full-screen 
view
@@ -10721,7 +11383,6 @@ def auto_triage(
                 updated_before=updated_before,
                 checks_state=checks_state,
                 min_commits_behind=min_commits_behind,
-                include_drafts=include_drafts,
                 author_filter=author_filter,
                 sort=sort,
                 batch_size=batch_size,
@@ -10779,26 +11440,89 @@ def auto_triage(
             )
         else:
             # Sequential mode (CI / forced answer / no TTY)
+            # Set up background prefetch of the next page — triggers at 75 % 
of this batch.
+            _prefetch_kwargs = dict(
+                token=token,
+                github_repository=github_repository,
+                exact_labels=exact_labels,
+                exact_exclude_labels=exact_exclude_labels,
+                filter_user=filter_user,
+                sort=sort,
+                batch_size=batch_size,
+                created_after=created_after,
+                created_before=created_before,
+                updated_after=updated_after,
+                updated_before=updated_before,
+                review_requested=review_requested_user,
+                next_cursor=next_cursor,
+                wildcard_labels=wildcard_labels,
+                wildcard_exclude_labels=wildcard_exclude_labels,
+                author_filter=author_filter,
+                include_drafts=include_drafts,
+                checks_state=checks_state,
+                min_commits_behind=min_commits_behind,
+                max_num=max_num,
+                viewer_login=viewer_login,
+                run_api=run_api,
+            )
+            _total_review_items = (
+                len(pending_approval)
+                + len(det_flagged_prs)
+                + len(llm_candidates)
+                + len(passing_prs)
+                + len(accepted_prs)
+                + len(already_triaged)
+                + len(stale_draft_prs)
+                + len(inactive_open_prs)
+                + len(stale_workflow_prs)
+            )
+            prefetcher = _SequentialPrefetcher(
+                total_items=_total_review_items,
+                has_next_page=has_next_page and not pr_number,
+                fetch_kwargs=_prefetch_kwargs,
+            )
+
             # Phase 4b: Present NOT_RUN PRs for workflow approval (LLM runs in 
background)
+            _reviewed = 0
             _review_workflow_approval_prs(ctx, pending_approval)
+            _reviewed += len(pending_approval)
+            prefetcher.maybe_start(_reviewed)
 
             # Phase 5a: Present deterministically flagged PRs
             _review_deterministic_flagged_prs(ctx, det_flagged_prs)
+            _reviewed += len(det_flagged_prs)
+            prefetcher.maybe_start(_reviewed)
 
             # Phase 5b: Present LLM-flagged PRs as they become ready 
(streaming)
             _review_llm_flagged_prs(ctx, llm_candidates)
+            _reviewed += len(llm_candidates)
+            prefetcher.maybe_start(_reviewed)
 
             # Add LLM passing PRs to the passing list
             passing_prs.extend(llm_passing)
 
             # Phase 5c: Present passing PRs for optional ready-for-review 
marking
             _review_passing_prs(ctx, passing_prs)
+            _reviewed += len(passing_prs)
+            prefetcher.maybe_start(_reviewed)
 
             # Phase 5d: Check accepted PRs for stale CHANGES_REQUESTED reviews
             _review_stale_review_requests(ctx, accepted_prs)
+            _reviewed += len(accepted_prs)
+            prefetcher.maybe_start(_reviewed)
 
             # Phase 5e: Present already-triaged PRs for optional re-evaluation
             _review_already_triaged_prs(ctx, already_triaged, run_api=run_api)
+
+            # Phase 5f: Close stale draft PRs
+            _triaged_stale_nums = 
enrich_result.triaged_classification["stale_draft"]
+            _close_stale_draft_prs(ctx, stale_draft_prs, 
triaged_stale_nums=_triaged_stale_nums)
+
+            # Phase 5g: Convert inactive open PRs to draft (no activity for >4 
weeks)
+            _draft_inactive_open_prs(ctx, inactive_open_prs)
+
+            # Phase 5h: Convert stale workflow-approval PRs to draft (no 
activity for >4 weeks)
+            _draft_stale_workflow_prs(ctx, stale_workflow_prs)
     except KeyboardInterrupt:
         console_print("\n[warning]Interrupted — shutting down.[/]")
         stats.quit_early = True
@@ -10807,11 +11531,21 @@ def auto_triage(
         if llm_executor is not None:
             llm_executor.shutdown(wait=False, cancel_futures=True)
 
-    # Fetch and process next batch if available and user hasn't quit
+    # Fetch and process next batch if available and user hasn't quit.
+    # If the prefetcher already fetched the next batch in the background, use 
it.
+    _pending_prefetch: _PrefetchedBatch | None = None
+    if prefetcher is not None:
+        if prefetcher.started and not stats.quit_early:
+            console_print("[dim]Waiting for background prefetch to 
complete...[/]")
+            _pending_prefetch = prefetcher.get_result()
+        prefetcher.shutdown()
     while has_next_page and not stats.quit_early and not pr_number:
         batch_num = getattr(stats, "_batch_count", 1) + 1
         stats._batch_count = batch_num  # type: ignore[attr-defined]
-        console_print(f"\n[info]Batch complete. Fetching next batch (page 
{batch_num})...[/]\n")
+        if _pending_prefetch:
+            console_print(f"\n[info]Batch complete. Next batch already 
prefetched (page {batch_num}).[/]\n")
+        else:
+            console_print(f"\n[info]Batch complete. Fetching next batch (page 
{batch_num})...[/]\n")
         has_next_page, next_cursor = _process_pagination_batch(
             token=token,
             github_repository=github_repository,
@@ -10843,7 +11577,9 @@ def auto_triage(
             main_failures=main_failures,
             stats=stats,
             accepted_prs=accepted_prs,
+            prefetched=_pending_prefetch,
         )
+        _pending_prefetch = None  # Only use prefetched data for the first 
iteration
 
     # Session summary
     t_total_end = time.monotonic()
diff --git a/dev/breeze/src/airflow_breeze/commands/pr_commands_config.py 
b/dev/breeze/src/airflow_breeze/commands/pr_commands_config.py
index 8311a29d26a..a791d5588c9 100644
--- a/dev/breeze/src/airflow_breeze/commands/pr_commands_config.py
+++ b/dev/breeze/src/airflow_breeze/commands/pr_commands_config.py
@@ -49,7 +49,6 @@ PR_PARAMETERS: dict[str, list[dict[str, str | list[str]]]] = {
                 "--created-before",
                 "--updated-after",
                 "--updated-before",
-                "--include-drafts",
                 "--pending-approval-only",
                 "--checks-state",
                 "--min-commits-behind",
diff --git a/dev/breeze/src/airflow_breeze/utils/confirm.py 
b/dev/breeze/src/airflow_breeze/utils/confirm.py
index afce5fc8e92..d21e34152aa 100644
--- a/dev/breeze/src/airflow_breeze/utils/confirm.py
+++ b/dev/breeze/src/airflow_breeze/utils/confirm.py
@@ -148,6 +148,7 @@ class TriageAction(Enum):
     OPEN = "o"
     SHOW = "e"
     READY = "m"
+    REVIEW_MERGE = "g"
     SKIP = "s"
     QUIT = "q"
     BACK = "ESC"
@@ -215,14 +216,15 @@ def _show_pr_diff(token: str, github_repository: str, 
pr_number: int, pr_url: st
 class ContinueAction(Enum):
     CONTINUE = "c"
     FLAG = "f"
+    SKIP = "s"
     QUIT = "q"
 
 
 def prompt_space_continue(
-    message: str = "Press Enter to continue, \\[f] to flag as suspicious, 
\\[q] to quit",
+    message: str = "Press Enter to continue, \\[s] to skip, \\[f] to flag as 
suspicious, \\[q] to quit",
     forced_answer: str | None = None,
 ) -> ContinueAction:
-    """Wait for the user to press Enter to continue, 'f' to flag, or 'q' to 
quit.
+    """Wait for the user to press Enter to continue, 's' to skip, 'f' to flag, 
or 'q' to quit.
 
     Used for scrolling through diffs one-by-one without asking yes/no 
questions.
     """
@@ -233,6 +235,8 @@ def prompt_space_continue(
             return ContinueAction.QUIT
         if upper in ("F", "FLAG"):
             return ContinueAction.FLAG
+        if upper in ("S", "SKIP"):
+            return ContinueAction.SKIP
         return ContinueAction.CONTINUE
 
     console_print(f"\n{message}: ", end="")
@@ -250,6 +254,9 @@ def prompt_space_continue(
         if ch in ("\r", "\n", ""):
             console_print()
             return ContinueAction.CONTINUE
+        if ch.upper() == "S":
+            console_print("skip")
+            return ContinueAction.SKIP
         if ch.upper() == "F":
             console_print("flag as suspicious")
             return ContinueAction.FLAG
@@ -299,6 +306,7 @@ def prompt_triage_action(
         TriageAction.SHOW: "show diff",
         TriageAction.BACK: "back to TUI",
         TriageAction.READY: "mark as ready",
+        TriageAction.REVIEW_MERGE: "review & merge",
         TriageAction.SKIP: "skip",
         TriageAction.QUIT: "quit",
     }
@@ -376,6 +384,14 @@ def prompt_triage_action(
                 else:
                     console_print("  [warning]No PR URL available to open.[/]")
                 continue  # re-prompt after opening browser
+            if matched == TriageAction.REVIEW_MERGE:
+                if pr_url:
+                    review_url = f"{pr_url}/files"
+                    webbrowser.open(review_url)
+                    console_print(f"  [info]Opened {review_url} in browser for 
review & merge.[/]")
+                else:
+                    console_print("  [warning]No PR URL available to open.[/]")
+                return matched
             if matched == TriageAction.SHOW:
                 if token and github_repository and pr_number:
                     _show_pr_diff(token, github_repository, pr_number, pr_url)
diff --git a/dev/breeze/src/airflow_breeze/utils/llm_utils.py 
b/dev/breeze/src/airflow_breeze/utils/llm_utils.py
index 2fc1d60d114..ee38e251e07 100644
--- a/dev/breeze/src/airflow_breeze/utils/llm_utils.py
+++ b/dev/breeze/src/airflow_breeze/utils/llm_utils.py
@@ -35,7 +35,9 @@ You are a pull request quality reviewer for the Apache 
Airflow open-source proje
 Your job is to assess whether a PR meets minimum quality criteria for 
maintainer review.
 
 NOTE: CI check failures (pre-commit, linting, mypy, tests) are detected 
automatically and handled
-separately — do NOT evaluate them. Focus only on the criteria below.
+separately — do NOT evaluate them. Focus only on the criteria below. Assume 
that generated files have
+been generated automatically and do not raise it as an issue - CI checks will 
detect when the
+generated files are not generated automatically.
 """
 
 _SYSTEM_PROMPT_SUFFIX = """
diff --git a/dev/breeze/src/airflow_breeze/utils/pr_cache.py 
b/dev/breeze/src/airflow_breeze/utils/pr_cache.py
index acd842e4e81..c1ca137e283 100644
--- a/dev/breeze/src/airflow_breeze/utils/pr_cache.py
+++ b/dev/breeze/src/airflow_breeze/utils/pr_cache.py
@@ -114,13 +114,23 @@ def save_classification_cache(
     classification_cache.save(github_repository, f"pr_{pr_number}", entry)
 
 
-def get_cached_assessment(github_repository: str, pr_number: int, head_sha: 
str) -> dict | None:
-    data = triage_cache.get(github_repository, f"pr_{pr_number}", 
match={"head_sha": head_sha})
+def get_cached_assessment(
+    github_repository: str, pr_number: int, head_sha: str, checks_state: str = 
""
+) -> dict | None:
+    match = {"head_sha": head_sha}
+    if checks_state:
+        match["checks_state"] = checks_state
+    data = triage_cache.get(github_repository, f"pr_{pr_number}", match=match)
     return data.get("assessment") if data else None
 
 
-def save_assessment_cache(github_repository: str, pr_number: int, head_sha: 
str, assessment: dict) -> None:
-    triage_cache.save(github_repository, f"pr_{pr_number}", {"head_sha": 
head_sha, "assessment": assessment})
+def save_assessment_cache(
+    github_repository: str, pr_number: int, head_sha: str, assessment: dict, 
checks_state: str = ""
+) -> None:
+    entry: dict[str, Any] = {"head_sha": head_sha, "assessment": assessment}
+    if checks_state:
+        entry["checks_state"] = checks_state
+    triage_cache.save(github_repository, f"pr_{pr_number}", entry)
 
 
 def get_cached_status(github_repository: str, cache_key: str) -> Any:
diff --git a/dev/breeze/src/airflow_breeze/utils/pr_display.py 
b/dev/breeze/src/airflow_breeze/utils/pr_display.py
index a30a87cb836..00980c00591 100644
--- a/dev/breeze/src/airflow_breeze/utils/pr_display.py
+++ b/dev/breeze/src/airflow_breeze/utils/pr_display.py
@@ -97,4 +97,18 @@ def print_pr_header(pr: PRData, index: int | None = None, 
total: int | None = No
     console.print(f"  [bold]{pr.title}[/]")
     console.print(f"  [dim]{pr.url}[/]")
     console.print(f"  Author: [bold]{pr.author_login}[/] 
({pr.author_association})")
+    if pr.review_decisions:
+        maintainer_assocs = {"COLLABORATOR", "MEMBER", "OWNER"}
+        maintainer_reviews = [r for r in pr.review_decisions if 
r.reviewer_association in maintainer_assocs]
+        if maintainer_reviews:
+            approved = [r for r in maintainer_reviews if r.state == "APPROVED"]
+            changes_requested = [r for r in maintainer_reviews if r.state == 
"CHANGES_REQUESTED"]
+            parts: list[str] = []
+            if approved:
+                names = ", ".join(r.reviewer_login for r in approved)
+                parts.append(f"[green]{len(approved)} approved[/] ({names})")
+            if changes_requested:
+                names = ", ".join(r.reviewer_login for r in changes_requested)
+                parts.append(f"[red]{len(changes_requested)} changes 
requested[/] ({names})")
+            console.print(f"  Maintainer reviews: {' | '.join(parts)}")
     console.print()
diff --git a/dev/breeze/src/airflow_breeze/utils/pr_models.py 
b/dev/breeze/src/airflow_breeze/utils/pr_models.py
index 9d92d357ca5..3c41ca16e64 100644
--- a/dev/breeze/src/airflow_breeze/utils/pr_models.py
+++ b/dev/breeze/src/airflow_breeze/utils/pr_models.py
@@ -18,7 +18,7 @@
 
 from __future__ import annotations
 
-from dataclasses import dataclass
+from dataclasses import dataclass, field
 
 # Number of hours after which a CI failure is considered "stale" and should be 
flagged.
 # Default grace period for new PRs without collaborator engagement.
@@ -28,6 +28,15 @@ CHECK_FAILURE_GRACE_PERIOD_HOURS = 24
 CHECK_FAILURE_GRACE_PERIOD_WITH_ENGAGEMENT_HOURS = 96
 
 
+@dataclass
+class ReviewDecision:
+    """A reviewer's latest review state on a PR."""
+
+    reviewer_login: str
+    state: str  # APPROVED, CHANGES_REQUESTED, COMMENTED, DISMISSED
+    reviewer_association: str = ""  # COLLABORATOR, MEMBER, OWNER, etc.
+
+
 @dataclass
 class UnresolvedThread:
     """Detail about a single unresolved review thread from a maintainer."""
@@ -67,6 +76,7 @@ class PRData:
     mergeable: str
     labels: list[str]
     unresolved_threads: list[UnresolvedThread]
+    review_decisions: list[ReviewDecision] = field(default_factory=list)
     has_collaborator_review: bool = False
 
     @property
diff --git a/dev/breeze/src/airflow_breeze/utils/tui_display.py 
b/dev/breeze/src/airflow_breeze/utils/tui_display.py
index 93cb229b05a..eb408bfae4a 100644
--- a/dev/breeze/src/airflow_breeze/utils/tui_display.py
+++ b/dev/breeze/src/airflow_breeze/utils/tui_display.py
@@ -34,6 +34,7 @@ from airflow_breeze.utils.confirm import _has_tty
 from airflow_breeze.utils.console import get_theme
 
 if TYPE_CHECKING:
+    from airflow_breeze.utils.github import PRAssessment
     from airflow_breeze.utils.pr_models import PRData
 
 
@@ -408,7 +409,7 @@ class TriageTUI:
         self._detail_visible_lines: int = 20
         self._detail_pr_number: int | None = None  # track which PR's details 
are built
         # Assessment data for flagged PRs (PR number → assessment)
-        self._assessments: dict[int, object] = {}
+        self._assessments: dict[int, PRAssessment] = {}
         # Focus state — which panel receives keyboard navigation
         self._focus: _FocusPanel = _FocusPanel.PR_LIST
         # Track previous cursor to detect PR changes for diff auto-fetch
@@ -484,7 +485,7 @@ class TriageTUI:
                             self.scroll_offset = self.cursor - 
self._visible_rows + 1
                     break
 
-    def set_assessments(self, assessments: dict[int, object]) -> None:
+    def set_assessments(self, assessments: dict[int, PRAssessment]) -> None:
         """Set assessment data for flagged PRs (PR number → PRAssessment)."""
         self._assessments = assessments
 
@@ -825,6 +826,24 @@ class TriageTUI:
                 author_line += f"  {' | '.join(parts)}"
         lines.append(author_line)
 
+        # Maintainer review decisions
+        if pr.review_decisions:
+            maintainer_assocs = {"COLLABORATOR", "MEMBER", "OWNER"}
+            maintainer_reviews = [
+                r for r in pr.review_decisions if r.reviewer_association in 
maintainer_assocs
+            ]
+            if maintainer_reviews:
+                approved = [r for r in maintainer_reviews if r.state == 
"APPROVED"]
+                changes_requested = [r for r in maintainer_reviews if r.state 
== "CHANGES_REQUESTED"]
+                review_parts: list[str] = []
+                if approved:
+                    names = ", ".join(r.reviewer_login for r in approved)
+                    review_parts.append(f"[green]{len(approved)} approved[/] 
({names})")
+                if changes_requested:
+                    names = ", ".join(r.reviewer_login for r in 
changes_requested)
+                    review_parts.append(f"[red]{len(changes_requested)} 
changes requested[/] ({names})")
+                lines.append(f"Maintainer reviews: {' | '.join(review_parts)}")
+
         # Timestamps
         lines.append(
             f"Created: {_human_readable_age(pr.created_at)}  |  Updated: 
{_human_readable_age(pr.updated_at)}"

Reply via email to