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 7e7d6951d63 Improve auto-triage: handle drafts, UX improvements, and 
smarter CI handling (#63350)
7e7d6951d63 is described below

commit 7e7d6951d6346313d81a5ade6e0d50554ca39046
Author: Jarek Potiuk <[email protected]>
AuthorDate: Wed Mar 11 21:59:49 2026 +0100

    Improve auto-triage: handle drafts, UX improvements, and smarter CI 
handling (#63350)
    
    Enhances the `breeze pr auto-triage` command with better draft PR support,
    improved interactive UX, and smarter CI/workflow handling.
    
    Draft PR handling:
    - Include draft PRs in workflow approval phase
    - Add --include-drafts option to include drafts in auto-triage
    - Skip draft PRs with workflows still running
    - Hide draft option for PRs that are already drafts
    - Add isDraft field to GraphQL queries and PRData dataclass
    
    Interactive UX improvements:
    - Use single-keypress input for all prompts (no Enter required)
    - Fall back to line-buffered input when no TTY is available
    - Remap keys: [m]ark as ready, [r]erun checks, [o]pen in browser,
      [c]omment, [x] close
    - Add confirmation prompt before every PR-modifying action
    - Show only violations initially, render comment after action choice
    - Allow selecting which violations to include in triage comments
    - Default to 'mark as ready' for passing PRs
    - Colorize author profile and show account age in green for established
      authors
    - Continue with next batch automatically after processing
    
    CI and workflow handling:
    - Add rerun checks option for PRs with 1-2 failing checks
    - Cancel and restart in-progress workflows when rerun-failed finds nothing
    - Only suggest rerun checks when PR is at most 50 commits behind
    - Skip CI failure flag when checks are running or names unavailable
    - Skip CI failure for any PR with workflows currently running
    - Try to approve workflows first, suggest draft on failure
    
    Review intelligence:
    - Detect stale CHANGES_REQUESTED reviews on accepted PRs and nudge
      follow-up
    - Skip workflow approval PRs already commented on without follow-up
    - Warn about .github/ and scripts/ changes when reviewing workflow
      approval
    
    Reliability:
    - Shut down LLM executor on KeyboardInterrupt for clean exit
    - Fix KeyError in batch continuation for author_flagged_count
    - Fix missing close_comment argument in draft action
    - Skip confirmation prompt when re-running checks in auto-triage
---
 dev/breeze/doc/images/output_pr_auto-triage.svg    |   78 +-
 dev/breeze/doc/images/output_pr_auto-triage.txt    |    2 +-
 .../src/airflow_breeze/commands/pr_commands.py     | 1409 ++++++++++++++++++--
 .../airflow_breeze/commands/pr_commands_config.py  |    1 +
 dev/breeze/src/airflow_breeze/utils/confirm.py     |  252 ++--
 5 files changed, 1492 insertions(+), 250 deletions(-)

diff --git a/dev/breeze/doc/images/output_pr_auto-triage.svg 
b/dev/breeze/doc/images/output_pr_auto-triage.svg
index f42c66722d3..39f6145ac1c 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 2197.2" 
xmlns="http://www.w3.org/2000/svg";>
+<svg class="rich-terminal" viewBox="0 0 1482 2246.0" 
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="2146.2" />
+      <rect x="0" y="0" width="1463.0" height="2195.0" />
     </clipPath>
     <clipPath id="breeze-pr-auto-triage-line-0">
     <rect x="0" y="1.5" width="1464" height="24.65"/>
@@ -306,9 +306,15 @@
 <clipPath id="breeze-pr-auto-triage-line-86">
     <rect x="0" y="2099.9" width="1464" height="24.65"/>
             </clipPath>
+<clipPath id="breeze-pr-auto-triage-line-87">
+    <rect x="0" y="2124.3" width="1464" height="24.65"/>
+            </clipPath>
+<clipPath id="breeze-pr-auto-triage-line-88">
+    <rect x="0" y="2148.7" 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="2195.2" 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="2244" 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"/>
@@ -374,38 +380,40 @@
 </text><text class="breeze-pr-auto-triage-r5" x="0" y="1313.2" 
textLength="12.2" clip-path="url(#breeze-pr-auto-triage-line-53)">│</text><text 
class="breeze-pr-auto-triage-r4" x="24.4" y="1313.2" textLength="280.6" 
clip-path="url(#breeze-pr-auto-triage-line-53)">--updated-after&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text
 class="breeze-pr-auto-triage-r1" x="329.4" y="1313.2" textLength="646.6" 
clip-path="url(#breeze-pr-auto-triage-line-53)">Only&#160;PRs&#160;updated&#160;
 [...]
 </text><text class="breeze-pr-auto-triage-r5" x="0" y="1337.6" 
textLength="12.2" clip-path="url(#breeze-pr-auto-triage-line-54)">│</text><text 
class="breeze-pr-auto-triage-r4" x="24.4" y="1337.6" textLength="280.6" 
clip-path="url(#breeze-pr-auto-triage-line-54)">--updated-before&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text
 class="breeze-pr-auto-triage-r1" x="329.4" y="1337.6" textLength="658.8" 
clip-path="url(#breeze-pr-auto-triage-line-54)">Only&#160;PRs&#160;updated&#160;on&#1
 [...]
 </text><text class="breeze-pr-auto-triage-r5" x="0" y="1362" textLength="12.2" 
clip-path="url(#breeze-pr-auto-triage-line-55)">│</text><text 
class="breeze-pr-auto-triage-r4" x="24.4" y="1362" textLength="280.6" 
clip-path="url(#breeze-pr-auto-triage-line-55)">--include-collaborators</text><text
 class="breeze-pr-auto-triage-r1" x="329.4" y="1362" textLength="793" 
clip-path="url(#breeze-pr-auto-triage-line-55)">Include&#160;PRs&#160;from&#160;collaborators/members/owners&#160;(normally&#160
 [...]
-</text><text class="breeze-pr-auto-triage-r5" x="0" y="1386.4" 
textLength="12.2" clip-path="url(#breeze-pr-auto-triage-line-56)">│</text><text 
class="breeze-pr-auto-triage-r4" x="24.4" y="1386.4" textLength="280.6" 
clip-path="url(#breeze-pr-auto-triage-line-56)">--pending-approval-only</text><text
 class="breeze-pr-auto-triage-r1" x="329.4" y="1386.4" textLength="622.2" 
clip-path="url(#breeze-pr-auto-triage-line-56)">Only&#160;show&#160;PRs&#160;with&#160;workflow&#160;runs&#160;awaiting&
 [...]
-</text><text class="breeze-pr-auto-triage-r5" x="0" y="1410.8" 
textLength="12.2" clip-path="url(#breeze-pr-auto-triage-line-57)">│</text><text 
class="breeze-pr-auto-triage-r4" x="24.4" y="1410.8" textLength="280.6" 
clip-path="url(#breeze-pr-auto-triage-line-57)">--checks-state&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text
 class="breeze-pr-auto-triage-r1" x="329.4" y="1410.8" textLength="524.6" 
clip-path="url(#breeze-pr-auto-triage-line-57)">Only&#160;assess&#160;PRs&# 
[...]
-</text><text class="breeze-pr-auto-triage-r5" x="0" y="1435.2" 
textLength="12.2" clip-path="url(#breeze-pr-auto-triage-line-58)">│</text><text 
class="breeze-pr-auto-triage-r4" x="24.4" y="1435.2" textLength="280.6" 
clip-path="url(#breeze-pr-auto-triage-line-58)">--min-commits-behind&#160;&#160;&#160;</text><text
 class="breeze-pr-auto-triage-r1" x="329.4" y="1435.2" textLength="878.4" 
clip-path="url(#breeze-pr-auto-triage-line-58)">Only&#160;assess&#160;PRs&#160;that&#160;are&#160;at&#160
 [...]
-</text><text class="breeze-pr-auto-triage-r5" x="0" y="1459.6" 
textLength="12.2" clip-path="url(#breeze-pr-auto-triage-line-59)">│</text><text 
class="breeze-pr-auto-triage-r6" x="329.4" y="1459.6" textLength="109.8" 
clip-path="url(#breeze-pr-auto-triage-line-59)">(INTEGER)</text><text 
class="breeze-pr-auto-triage-r5" x="1451.8" y="1459.6" textLength="12.2" 
clip-path="url(#breeze-pr-auto-triage-line-59)">│</text><text 
class="breeze-pr-auto-triage-r1" x="1464" y="1459.6" textLength="12.2"  [...]
-</text><text class="breeze-pr-auto-triage-r5" x="0" y="1484" textLength="12.2" 
clip-path="url(#breeze-pr-auto-triage-line-60)">│</text><text 
class="breeze-pr-auto-triage-r4" x="24.4" y="1484" textLength="280.6" 
clip-path="url(#breeze-pr-auto-triage-line-60)">--review-requested&#160;&#160;&#160;&#160;&#160;</text><text
 class="breeze-pr-auto-triage-r1" x="329.4" y="1484" textLength="817.4" 
clip-path="url(#breeze-pr-auto-triage-line-60)">Only&#160;show&#160;PRs&#160;where&#160;review&#160;i
 [...]
-</text><text class="breeze-pr-auto-triage-r5" x="0" y="1508.4" 
textLength="1464" 
clip-path="url(#breeze-pr-auto-triage-line-61)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
 class="breeze-pr-auto-triage-r1" x="1464" y="1508.4" textLength="12.2" 
clip-path="url(#breeze-pr-auto-triage-line-61)">
-</text><text class="breeze-pr-auto-triage-r5" x="0" y="1532.8" 
textLength="24.4" 
clip-path="url(#breeze-pr-auto-triage-line-62)">╭─</text><text 
class="breeze-pr-auto-triage-r5" x="24.4" y="1532.8" textLength="292.8" 
clip-path="url(#breeze-pr-auto-triage-line-62)">&#160;Pagination&#160;and&#160;sorting&#160;</text><text
 class="breeze-pr-auto-triage-r5" x="317.2" y="1532.8" textLength="1122.4" 
clip-path="url(#breeze-pr-auto-triage-line-62)">─────────────────────────────────────────────────
 [...]
-</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="146.4" 
clip-path="url(#breeze-pr-auto-triage-line-63)">--batch-size</text><text 
class="breeze-pr-auto-triage-r1" x="195.2" y="1557.2" textLength="500.2" 
clip-path="url(#breeze-pr-auto-triage-line-63)">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="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="146.4" 
clip-path="url(#breeze-pr-auto-triage-line-64)">--max-num&#160;&#160;&#160;</text><text
 class="breeze-pr-auto-triage-r1" x="195.2" y="1581.6" textLength="793" 
clip-path="url(#breeze-pr-auto-triage-line-64)">Maximum&#160;number&#160;of&#160;non-collaborator&#160;PRs&#160;to&#16
 [...]
-</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="146.4" 
clip-path="url(#breeze-pr-auto-triage-line-65)">--sort&#160;&#160;&#160;&#160;&#160;&#160;</text><text
 class="breeze-pr-auto-triage-r1" x="195.2" y="1606" textLength="414.8" 
clip-path="url(#breeze-pr-auto-triage-line-65)">Sort&#160;order&#160;for&#160;PR&#160;search&#160;results.&
 [...]
-</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-r6" x="195.2" y="1630.4" textLength="622.2" 
clip-path="url(#breeze-pr-auto-triage-line-66)">(created-asc|created-desc|updated-asc|updated-desc)</text><text
 class="breeze-pr-auto-triage-r5" x="1451.8" y="1630.4" textLength="12.2" 
clip-path="url(#breeze-pr-auto-triage-line-66)">│</text><text 
class="breeze-pr-auto-triage- [...]
-</text><text class="breeze-pr-auto-triage-r5" x="0" y="1654.8" 
textLength="1464" 
clip-path="url(#breeze-pr-auto-triage-line-67)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
 class="breeze-pr-auto-triage-r1" x="1464" y="1654.8" textLength="12.2" 
clip-path="url(#breeze-pr-auto-triage-line-67)">
-</text><text class="breeze-pr-auto-triage-r5" x="0" y="1679.2" 
textLength="24.4" 
clip-path="url(#breeze-pr-auto-triage-line-68)">╭─</text><text 
class="breeze-pr-auto-triage-r5" x="24.4" y="1679.2" textLength="244" 
clip-path="url(#breeze-pr-auto-triage-line-68)">&#160;Assessment&#160;options&#160;</text><text
 class="breeze-pr-auto-triage-r5" x="268.4" y="1679.2" textLength="1171.2" 
clip-path="url(#breeze-pr-auto-triage-line-68)">────────────────────────────────────────────────────────────
 [...]
-</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="207.4" 
clip-path="url(#breeze-pr-auto-triage-line-69)">--check-mode&#160;&#160;&#160;&#160;&#160;</text><text
 class="breeze-pr-auto-triage-r1" x="256.2" y="1703.6" textLength="1037" 
clip-path="url(#breeze-pr-auto-triage-line-69)">Which&#160;checks&#160;to&#160;run:&#160;&#x27;both&#x
 [...]
-</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-r5" x="256.2" y="1728" textLength="61" 
clip-path="url(#breeze-pr-auto-triage-line-70)">both]</text><text 
class="breeze-pr-auto-triage-r6" x="329.4" y="1728" textLength="158.6" 
clip-path="url(#breeze-pr-auto-triage-line-70)">(both|ci|llm)</text><text 
class="breeze-pr-auto-triage-r5" x="1451.8" y="1728" textLength="12.2" c [...]
-</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-r4" x="24.4" y="1752.4" textLength="207.4" 
clip-path="url(#breeze-pr-auto-triage-line-71)">--llm-model&#160;&#160;&#160;&#160;&#160;&#160;</text><text
 class="breeze-pr-auto-triage-r1" x="256.2" y="1752.4" textLength="1183.4" 
clip-path="url(#breeze-pr-auto-triage-line-71)">LLM&#160;model&#160;for&#160;assessment&#160;(f
 [...]
-</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-r1" x="256.2" y="1776.8" textLength="268.4" 
clip-path="url(#breeze-pr-auto-triage-line-72)">for&#160;OpenAI&#160;Codex&#160;CLI.&#160;</text><text
 class="breeze-pr-auto-triage-r5" x="524.6" y="1776.8" textLength="427" 
clip-path="url(#breeze-pr-auto-triage-line-72)">[default:&#160;claude/claude-sonnet-4-6]</text><text
 c [...]
-</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-r6" x="256.2" y="1801.2" textLength="1159" 
clip-path="url(#breeze-pr-auto-triage-line-73)">&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="1801.2" textLength="12.2" 
clip-path="u [...]
-</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-r6" x="256.2" y="1825.6" textLength="976" 
clip-path="url(#breeze-pr-auto-triage-line-74)">claude/claude-haiku-4-5-20251001&#160;|&#160;claude/sonnet&#160;|&#160;claude/opus&#160;|&#160;claude/haiku&#160;|&#160;</text><text
 class="breeze-pr-auto-triage-r5" x="1451.8" y="1825.6" textLength="12.2" 
clip-path="url(#breeze-p [...]
-</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="256.2" y="1850" textLength="1146.8" 
clip-path="url(#breeze-pr-auto-triage-line-75)">codex/gpt-5.3-codex&#160;|&#160;codex/gpt-5.3-codex-spark&#160;|&#160;codex/gpt-5.2-codex&#160;|&#160;codex/gpt-5.1-codex&#160;|&#160;</text><text
 class="breeze-pr-auto-triage-r5" x="1451.8" y="1850" textLength="12.2" 
clip-path="ur [...]
-</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-r6" x="256.2" y="1874.4" textLength="695.4" 
clip-path="url(#breeze-pr-auto-triage-line-76)">codex/gpt-5-codex&#160;|&#160;codex/gpt-5-codex-mini&#160;|&#160;codex/gpt-5)</text><text
 class="breeze-pr-auto-triage-r5" x="1451.8" y="1874.4" textLength="12.2" 
clip-path="url(#breeze-pr-auto-triage-line-76)">│</text><text cla [...]
-</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-r4" x="24.4" y="1898.8" textLength="207.4" 
clip-path="url(#breeze-pr-auto-triage-line-77)">--llm-concurrency</text><text 
class="breeze-pr-auto-triage-r1" x="256.2" y="1898.8" textLength="524.6" 
clip-path="url(#breeze-pr-auto-triage-line-77)">Number&#160;of&#160;concurrent&#160;LLM&#160;assessment&#160;calls.&#160;</tex
 [...]
-</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="195.2" 
clip-path="url(#breeze-pr-auto-triage-line-79)">&#160;Action&#160;options&#160;</text><text
 class="breeze-pr-auto-triage-r5" x="219.6" y="1947.6" textLength="1220" 
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="183" 
clip-path="url(#breeze-pr-auto-triage-line-80)">--answer-triage</text><text 
class="breeze-pr-auto-triage-r1" x="231.8" y="1972" textLength="1207.8" 
clip-path="url(#breeze-pr-auto-triage-line-80)">Force&#160;answer&#160;to&#160;triage&#160;prompts:&#160;[d]raft,&#160;[c]lose,&#160;[r
 [...]
-</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="231.8" y="1996.4" textLength="183" 
clip-path="url(#breeze-pr-auto-triage-line-81)">(d|c|r|s|q|y|n)</text><text 
class="breeze-pr-auto-triage-r5" x="1451.8" y="1996.4" textLength="12.2" 
clip-path="url(#breeze-pr-auto-triage-line-81)">│</text><text 
class="breeze-pr-auto-triage-r1" x="1464" y="1996.4" textLength="12 [...]
-</text><text class="breeze-pr-auto-triage-r5" x="0" y="2020.8" 
textLength="1464" 
clip-path="url(#breeze-pr-auto-triage-line-82)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
 class="breeze-pr-auto-triage-r1" x="1464" y="2020.8" textLength="12.2" 
clip-path="url(#breeze-pr-auto-triage-line-82)">
-</text><text class="breeze-pr-auto-triage-r5" x="0" y="2045.2" 
textLength="24.4" 
clip-path="url(#breeze-pr-auto-triage-line-83)">╭─</text><text 
class="breeze-pr-auto-triage-r5" x="24.4" y="2045.2" textLength="195.2" 
clip-path="url(#breeze-pr-auto-triage-line-83)">&#160;Common&#160;options&#160;</text><text
 class="breeze-pr-auto-triage-r5" x="219.6" y="2045.2" textLength="1220" 
clip-path="url(#breeze-pr-auto-triage-line-83)">────────────────────────────────────────────────────────────────
 [...]
-</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="109.8" 
clip-path="url(#breeze-pr-auto-triage-line-84)">--dry-run</text><text 
class="breeze-pr-auto-triage-r7" x="158.6" y="2069.6" textLength="24.4" 
clip-path="url(#breeze-pr-auto-triage-line-84)">-D</text><text 
class="breeze-pr-auto-triage-r1" x="207.4" y="2069.6" textLength="719.8" [...]
-</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="109.8" 
clip-path="url(#breeze-pr-auto-triage-line-85)">--verbose</text><text 
class="breeze-pr-auto-triage-r7" x="158.6" y="2094" textLength="24.4" 
clip-path="url(#breeze-pr-auto-triage-line-85)">-v</text><text 
class="breeze-pr-auto-triage-r1" x="207.4" y="2094" textLength="585.6" clip-pa 
[...]
-</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="109.8" 
clip-path="url(#breeze-pr-auto-triage-line-86)">--help&#160;&#160;&#160;</text><text
 class="breeze-pr-auto-triage-r7" x="158.6" y="2118.4" textLength="24.4" 
clip-path="url(#breeze-pr-auto-triage-line-86)">-h</text><text 
class="breeze-pr-auto-triage-r1" x="207.4" y="2118.4" tex [...]
-</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="1386.4" 
textLength="12.2" clip-path="url(#breeze-pr-auto-triage-line-56)">│</text><text 
class="breeze-pr-auto-triage-r4" x="24.4" y="1386.4" textLength="280.6" 
clip-path="url(#breeze-pr-auto-triage-line-56)">--include-drafts&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text
 class="breeze-pr-auto-triage-r1" x="329.4" y="1386.4" textLength="1110.2" 
clip-path="url(#breeze-pr-auto-triage-line-56)">Include&#160;draft&#160;PRs&#160;in&
 [...]
+</text><text class="breeze-pr-auto-triage-r5" x="0" y="1410.8" 
textLength="12.2" clip-path="url(#breeze-pr-auto-triage-line-57)">│</text><text 
class="breeze-pr-auto-triage-r1" x="329.4" y="1410.8" textLength="1110.2" 
clip-path="url(#breeze-pr-auto-triage-line-57)">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="1435.2" 
textLength="12.2" clip-path="url(#breeze-pr-auto-triage-line-58)">│</text><text 
class="breeze-pr-auto-triage-r4" x="24.4" y="1435.2" textLength="280.6" 
clip-path="url(#breeze-pr-auto-triage-line-58)">--pending-approval-only</text><text
 class="breeze-pr-auto-triage-r1" x="329.4" y="1435.2" textLength="622.2" 
clip-path="url(#breeze-pr-auto-triage-line-58)">Only&#160;show&#160;PRs&#160;with&#160;workflow&#160;runs&#160;awaiting&
 [...]
+</text><text class="breeze-pr-auto-triage-r5" x="0" y="1459.6" 
textLength="12.2" clip-path="url(#breeze-pr-auto-triage-line-59)">│</text><text 
class="breeze-pr-auto-triage-r4" x="24.4" y="1459.6" textLength="280.6" 
clip-path="url(#breeze-pr-auto-triage-line-59)">--checks-state&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text
 class="breeze-pr-auto-triage-r1" x="329.4" y="1459.6" textLength="524.6" 
clip-path="url(#breeze-pr-auto-triage-line-59)">Only&#160;assess&#160;PRs&# 
[...]
+</text><text class="breeze-pr-auto-triage-r5" x="0" y="1484" textLength="12.2" 
clip-path="url(#breeze-pr-auto-triage-line-60)">│</text><text 
class="breeze-pr-auto-triage-r4" x="24.4" y="1484" textLength="280.6" 
clip-path="url(#breeze-pr-auto-triage-line-60)">--min-commits-behind&#160;&#160;&#160;</text><text
 class="breeze-pr-auto-triage-r1" x="329.4" y="1484" textLength="878.4" 
clip-path="url(#breeze-pr-auto-triage-line-60)">Only&#160;assess&#160;PRs&#160;that&#160;are&#160;at&#160;least
 [...]
+</text><text class="breeze-pr-auto-triage-r5" x="0" y="1508.4" 
textLength="12.2" clip-path="url(#breeze-pr-auto-triage-line-61)">│</text><text 
class="breeze-pr-auto-triage-r6" x="329.4" y="1508.4" textLength="109.8" 
clip-path="url(#breeze-pr-auto-triage-line-61)">(INTEGER)</text><text 
class="breeze-pr-auto-triage-r5" x="1451.8" y="1508.4" textLength="12.2" 
clip-path="url(#breeze-pr-auto-triage-line-61)">│</text><text 
class="breeze-pr-auto-triage-r1" x="1464" y="1508.4" textLength="12.2"  [...]
+</text><text class="breeze-pr-auto-triage-r5" x="0" y="1532.8" 
textLength="12.2" clip-path="url(#breeze-pr-auto-triage-line-62)">│</text><text 
class="breeze-pr-auto-triage-r4" x="24.4" y="1532.8" textLength="280.6" 
clip-path="url(#breeze-pr-auto-triage-line-62)">--review-requested&#160;&#160;&#160;&#160;&#160;</text><text
 class="breeze-pr-auto-triage-r1" x="329.4" y="1532.8" textLength="817.4" 
clip-path="url(#breeze-pr-auto-triage-line-62)">Only&#160;show&#160;PRs&#160;where&#160;review&
 [...]
+</text><text class="breeze-pr-auto-triage-r5" x="0" y="1557.2" 
textLength="1464" 
clip-path="url(#breeze-pr-auto-triage-line-63)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
 class="breeze-pr-auto-triage-r1" x="1464" y="1557.2" textLength="12.2" 
clip-path="url(#breeze-pr-auto-triage-line-63)">
+</text><text class="breeze-pr-auto-triage-r5" x="0" y="1581.6" 
textLength="24.4" 
clip-path="url(#breeze-pr-auto-triage-line-64)">╭─</text><text 
class="breeze-pr-auto-triage-r5" x="24.4" y="1581.6" textLength="292.8" 
clip-path="url(#breeze-pr-auto-triage-line-64)">&#160;Pagination&#160;and&#160;sorting&#160;</text><text
 class="breeze-pr-auto-triage-r5" x="317.2" y="1581.6" textLength="1122.4" 
clip-path="url(#breeze-pr-auto-triage-line-64)">─────────────────────────────────────────────────
 [...]
+</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="146.4" 
clip-path="url(#breeze-pr-auto-triage-line-65)">--batch-size</text><text 
class="breeze-pr-auto-triage-r1" x="195.2" y="1606" textLength="500.2" 
clip-path="url(#breeze-pr-auto-triage-line-65)">Number&#160;of&#160;PRs&#160;to&#160;fetch&#160;per&#160;GraphQL&#160;page.&#160;</text><
 [...]
+</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="146.4" 
clip-path="url(#breeze-pr-auto-triage-line-66)">--max-num&#160;&#160;&#160;</text><text
 class="breeze-pr-auto-triage-r1" x="195.2" y="1630.4" textLength="793" 
clip-path="url(#breeze-pr-auto-triage-line-66)">Maximum&#160;number&#160;of&#160;non-collaborator&#160;PRs&#160;to&#16
 [...]
+</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="146.4" 
clip-path="url(#breeze-pr-auto-triage-line-67)">--sort&#160;&#160;&#160;&#160;&#160;&#160;</text><text
 class="breeze-pr-auto-triage-r1" x="195.2" y="1654.8" textLength="414.8" 
clip-path="url(#breeze-pr-auto-triage-line-67)">Sort&#160;order&#160;for&#160;PR&#160;search&#160;res
 [...]
+</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-r6" x="195.2" y="1679.2" textLength="622.2" 
clip-path="url(#breeze-pr-auto-triage-line-68)">(created-asc|created-desc|updated-asc|updated-desc)</text><text
 class="breeze-pr-auto-triage-r5" x="1451.8" y="1679.2" textLength="12.2" 
clip-path="url(#breeze-pr-auto-triage-line-68)">│</text><text 
class="breeze-pr-auto-triage- [...]
+</text><text class="breeze-pr-auto-triage-r5" x="0" y="1703.6" 
textLength="1464" 
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" 
clip-path="url(#breeze-pr-auto-triage-line-69)">
+</text><text class="breeze-pr-auto-triage-r5" x="0" y="1728" textLength="24.4" 
clip-path="url(#breeze-pr-auto-triage-line-70)">╭─</text><text 
class="breeze-pr-auto-triage-r5" x="24.4" y="1728" textLength="244" 
clip-path="url(#breeze-pr-auto-triage-line-70)">&#160;Assessment&#160;options&#160;</text><text
 class="breeze-pr-auto-triage-r5" x="268.4" y="1728" textLength="1171.2" 
clip-path="url(#breeze-pr-auto-triage-line-70)">──────────────────────────────────────────────────────────────────
 [...]
+</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-r4" x="24.4" y="1752.4" textLength="207.4" 
clip-path="url(#breeze-pr-auto-triage-line-71)">--check-mode&#160;&#160;&#160;&#160;&#160;</text><text
 class="breeze-pr-auto-triage-r1" x="256.2" y="1752.4" textLength="1037" 
clip-path="url(#breeze-pr-auto-triage-line-71)">Which&#160;checks&#160;to&#160;run:&#160;&#x27;both&#x
 [...]
+</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-r5" x="256.2" y="1776.8" textLength="61" 
clip-path="url(#breeze-pr-auto-triage-line-72)">both]</text><text 
class="breeze-pr-auto-triage-r6" x="329.4" y="1776.8" textLength="158.6" 
clip-path="url(#breeze-pr-auto-triage-line-72)">(both|ci|llm)</text><text 
class="breeze-pr-auto-triage-r5" x="1451.8" y="1776.8" textLength= [...]
+</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="207.4" 
clip-path="url(#breeze-pr-auto-triage-line-73)">--llm-model&#160;&#160;&#160;&#160;&#160;&#160;</text><text
 class="breeze-pr-auto-triage-r1" x="256.2" y="1801.2" textLength="1183.4" 
clip-path="url(#breeze-pr-auto-triage-line-73)">LLM&#160;model&#160;for&#160;assessment&#160;(f
 [...]
+</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-r1" x="256.2" y="1825.6" textLength="268.4" 
clip-path="url(#breeze-pr-auto-triage-line-74)">for&#160;OpenAI&#160;Codex&#160;CLI.&#160;</text><text
 class="breeze-pr-auto-triage-r5" x="524.6" y="1825.6" textLength="427" 
clip-path="url(#breeze-pr-auto-triage-line-74)">[default:&#160;claude/claude-sonnet-4-6]</text><text
 c [...]
+</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="256.2" y="1850" textLength="1159" 
clip-path="url(#breeze-pr-auto-triage-line-75)">&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="1850" textLength="12.2" 
clip-path="url(#br [...]
+</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-r6" x="256.2" y="1874.4" textLength="976" 
clip-path="url(#breeze-pr-auto-triage-line-76)">claude/claude-haiku-4-5-20251001&#160;|&#160;claude/sonnet&#160;|&#160;claude/opus&#160;|&#160;claude/haiku&#160;|&#160;</text><text
 class="breeze-pr-auto-triage-r5" x="1451.8" y="1874.4" textLength="12.2" 
clip-path="url(#breeze-p [...]
+</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="256.2" y="1898.8" textLength="1146.8" 
clip-path="url(#breeze-pr-auto-triage-line-77)">codex/gpt-5.3-codex&#160;|&#160;codex/gpt-5.3-codex-spark&#160;|&#160;codex/gpt-5.2-codex&#160;|&#160;codex/gpt-5.1-codex&#160;|&#160;</text><text
 class="breeze-pr-auto-triage-r5" x="1451.8" y="1898.8" textLength="12.2" 
clip-pa [...]
+</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-r6" x="256.2" y="1923.2" textLength="695.4" 
clip-path="url(#breeze-pr-auto-triage-line-78)">codex/gpt-5-codex&#160;|&#160;codex/gpt-5-codex-mini&#160;|&#160;codex/gpt-5)</text><text
 class="breeze-pr-auto-triage-r5" x="1451.8" y="1923.2" textLength="12.2" 
clip-path="url(#breeze-pr-auto-triage-line-78)">│</text><text cla [...]
+</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-r4" x="24.4" y="1947.6" textLength="207.4" 
clip-path="url(#breeze-pr-auto-triage-line-79)">--llm-concurrency</text><text 
class="breeze-pr-auto-triage-r1" x="256.2" y="1947.6" textLength="524.6" 
clip-path="url(#breeze-pr-auto-triage-line-79)">Number&#160;of&#160;concurrent&#160;LLM&#160;assessment&#160;calls.&#160;</tex
 [...]
+</text><text class="breeze-pr-auto-triage-r5" x="0" y="1972" textLength="1464" 
clip-path="url(#breeze-pr-auto-triage-line-80)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
 class="breeze-pr-auto-triage-r1" x="1464" y="1972" textLength="12.2" 
clip-path="url(#breeze-pr-auto-triage-line-80)">
+</text><text class="breeze-pr-auto-triage-r5" x="0" y="1996.4" 
textLength="24.4" 
clip-path="url(#breeze-pr-auto-triage-line-81)">╭─</text><text 
class="breeze-pr-auto-triage-r5" x="24.4" y="1996.4" textLength="195.2" 
clip-path="url(#breeze-pr-auto-triage-line-81)">&#160;Action&#160;options&#160;</text><text
 class="breeze-pr-auto-triage-r5" x="219.6" y="1996.4" textLength="1220" 
clip-path="url(#breeze-pr-auto-triage-line-81)">────────────────────────────────────────────────────────────────
 [...]
+</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-r4" x="24.4" y="2020.8" textLength="183" 
clip-path="url(#breeze-pr-auto-triage-line-82)">--answer-triage</text><text 
class="breeze-pr-auto-triage-r1" x="231.8" y="2020.8" textLength="1207.8" 
clip-path="url(#breeze-pr-auto-triage-line-82)">Force&#160;answer&#160;to&#160;triage&#160;prompts:&#160;[d]raft,&#160;[c]lose,&#
 [...]
+</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="231.8" y="2045.2" textLength="183" 
clip-path="url(#breeze-pr-auto-triage-line-83)">(d|c|r|s|q|y|n)</text><text 
class="breeze-pr-auto-triage-r5" x="1451.8" y="2045.2" textLength="12.2" 
clip-path="url(#breeze-pr-auto-triage-line-83)">│</text><text 
class="breeze-pr-auto-triage-r1" x="1464" y="2045.2" textLength="12 [...]
+</text><text class="breeze-pr-auto-triage-r5" x="0" y="2069.6" 
textLength="1464" 
clip-path="url(#breeze-pr-auto-triage-line-84)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
 class="breeze-pr-auto-triage-r1" x="1464" y="2069.6" textLength="12.2" 
clip-path="url(#breeze-pr-auto-triage-line-84)">
+</text><text class="breeze-pr-auto-triage-r5" x="0" y="2094" textLength="24.4" 
clip-path="url(#breeze-pr-auto-triage-line-85)">╭─</text><text 
class="breeze-pr-auto-triage-r5" x="24.4" y="2094" textLength="195.2" 
clip-path="url(#breeze-pr-auto-triage-line-85)">&#160;Common&#160;options&#160;</text><text
 class="breeze-pr-auto-triage-r5" x="219.6" y="2094" textLength="1220" 
clip-path="url(#breeze-pr-auto-triage-line-85)">──────────────────────────────────────────────────────────────────────
 [...]
+</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="109.8" 
clip-path="url(#breeze-pr-auto-triage-line-86)">--dry-run</text><text 
class="breeze-pr-auto-triage-r7" x="158.6" y="2118.4" textLength="24.4" 
clip-path="url(#breeze-pr-auto-triage-line-86)">-D</text><text 
class="breeze-pr-auto-triage-r1" x="207.4" y="2118.4" textLength="719.8" [...]
+</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="109.8" 
clip-path="url(#breeze-pr-auto-triage-line-87)">--verbose</text><text 
class="breeze-pr-auto-triage-r7" x="158.6" y="2142.8" textLength="24.4" 
clip-path="url(#breeze-pr-auto-triage-line-87)">-v</text><text 
class="breeze-pr-auto-triage-r1" x="207.4" y="2142.8" textLength="585.6" [...]
+</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-r4" x="24.4" y="2167.2" textLength="109.8" 
clip-path="url(#breeze-pr-auto-triage-line-88)">--help&#160;&#160;&#160;</text><text
 class="breeze-pr-auto-triage-r7" x="158.6" y="2167.2" textLength="24.4" 
clip-path="url(#breeze-pr-auto-triage-line-88)">-h</text><text 
class="breeze-pr-auto-triage-r1" x="207.4" y="2167.2" tex [...]
+</text><text class="breeze-pr-auto-triage-r5" x="0" y="2191.6" 
textLength="1464" 
clip-path="url(#breeze-pr-auto-triage-line-89)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
 class="breeze-pr-auto-triage-r1" x="1464" y="2191.6" textLength="12.2" 
clip-path="url(#breeze-pr-auto-triage-line-89)">
 </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 4bbfb2746b7..c002686029c 100644
--- a/dev/breeze/doc/images/output_pr_auto-triage.txt
+++ b/dev/breeze/doc/images/output_pr_auto-triage.txt
@@ -1 +1 @@
-5d0f5d66b4acab3d4df1a3667eeda300
+c8c9f4331a87750208ac66d22063d14e
diff --git a/dev/breeze/src/airflow_breeze/commands/pr_commands.py 
b/dev/breeze/src/airflow_breeze/commands/pr_commands.py
index e542f585a47..d16031ff2d7 100644
--- a/dev/breeze/src/airflow_breeze/commands/pr_commands.py
+++ b/dev/breeze/src/airflow_breeze/commands/pr_commands.py
@@ -63,6 +63,9 @@ _SUSPICIOUS_CHANGES_LABEL = "suspicious changes detected"
 # GitHub accounts that should be auto-skipped during triage
 _BOT_ACCOUNT_LOGINS = {"dependabot", "dependabot[bot]", "renovate[bot]", 
"github-actions[bot]"}
 
+# Marker used to identify comments posted by the auto-triage process
+_TRIAGE_COMMENT_MARKER = "Pull Request quality criteria"
+
 _SEARCH_PRS_QUERY = """
 query($query: String!, $first: Int!, $after: String) {
   search(query: $query, type: ISSUE, first: $first, after: $after) {
@@ -83,6 +86,7 @@ query($query: String!, $first: Int!, $after: String) {
         author { login }
         authorAssociation
         baseRefName
+        isDraft
         mergeable
         labels(first: 20) {
           nodes { name }
@@ -117,6 +121,7 @@ query($owner: String!, $repo: String!, $number: Int!) {
       author { login }
       authorAssociation
       baseRefName
+      isDraft
       mergeable
       labels(first: 20) {
         nodes { name }
@@ -208,6 +213,14 @@ mutation($prId: ID!) {
 }
 """
 
+_MARK_READY_FOR_REVIEW_MUTATION = """
+mutation($prId: ID!) {
+  markPullRequestReadyForReview(input: {pullRequestId: $prId}) {
+    pullRequest { id }
+  }
+}
+"""
+
 _ADD_COMMENT_MUTATION = """
 mutation($subjectId: ID!, $body: String!) {
   addComment(input: {subjectId: $subjectId, body: $body}) {
@@ -260,11 +273,21 @@ class PRData:
     checks_state: str  # statusCheckRollup.state: SUCCESS, FAILURE, PENDING, 
etc.
     failed_checks: list[str]  # best-effort list of individual failing check 
names
     commits_behind: int  # how many commits behind the base branch
+    is_draft: bool  # whether the PR is currently a draft
     mergeable: str  # MERGEABLE, CONFLICTING, or UNKNOWN
     labels: list[str]  # label names attached to this PR
     unresolved_review_comments: int  # count of unresolved review threads from 
maintainers
 
 
+@dataclass
+class StaleReviewInfo:
+    """Info about a CHANGES_REQUESTED review that may need a follow-up 
nudge."""
+
+    reviewer_login: str
+    review_date: str  # ISO 8601
+    author_pinged_reviewer: bool  # whether the author mentioned the reviewer 
after the review
+
+
 @click.group(cls=BreezeGroup, name="pr", help="Tools for managing GitHub pull 
requests.")
 def pr_group():
     pass
@@ -612,6 +635,235 @@ def _fetch_commits_behind_batch(token: str, 
github_repository: str, prs: list[PR
     return result
 
 
+def _find_already_triaged_prs(
+    token: str,
+    github_repository: str,
+    prs: list[PRData],
+    viewer_login: str,
+    *,
+    require_marker: bool = True,
+) -> set[int]:
+    """Find PRs that already have a triage comment posted after the last 
commit.
+
+    Returns a set of PR numbers that should be skipped because the triage 
process
+    already commented and no new commits were pushed since.
+
+    :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).
+    """
+    if not prs:
+        return set()
+
+    owner, repo = github_repository.split("/", 1)
+    already_triaged: set[int] = set()
+
+    # Batch fetch last 10 comments + last commit date for each PR
+    for chunk_start in range(0, len(prs), _COMMITS_BEHIND_BATCH_SIZE):
+        chunk = prs[chunk_start : chunk_start + _COMMITS_BEHIND_BATCH_SIZE]
+
+        pr_fields = []
+        for pr in chunk:
+            alias = f"pr{pr.number}"
+            pr_fields.append(
+                f"    {alias}: pullRequest(number: {pr.number}) {{\n"
+                f"      comments(last: 10) {{\n"
+                f"        nodes {{ author {{ login }} body createdAt }}\n"
+                f"      }}\n"
+                f"      commits(last: 1) {{\n"
+                f"        nodes {{ commit {{ committedDate }} }}\n"
+                f"      }}\n"
+                f"    }}"
+            )
+
+        query = (
+            f'query {{\n  repository(owner: "{owner}", name: "{repo}") {{\n'
+            + "\n".join(pr_fields)
+            + "\n  }\n}"
+        )
+
+        try:
+            data = _graphql_request(token, query, {})
+        except SystemExit:
+            continue
+
+        repo_data = data.get("repository", {})
+        for pr in chunk:
+            alias = f"pr{pr.number}"
+            pr_data = repo_data.get(alias) or {}
+
+            # Get last commit date
+            commits = pr_data.get("commits", {}).get("nodes", [])
+            last_commit_date = ""
+            if commits:
+                last_commit_date = commits[0].get("commit", 
{}).get("committedDate", "")
+
+            # Check if any comment is from the viewer and contains the triage 
marker
+            comments = pr_data.get("comments", {}).get("nodes", [])
+            for comment in reversed(comments):
+                author = (comment.get("author") or {}).get("login", "")
+                body = comment.get("body", "")
+                comment_date = comment.get("createdAt", "")
+
+                if (
+                    author == viewer_login
+                    and (not require_marker or _TRIAGE_COMMENT_MARKER in body)
+                    and comment_date >= last_commit_date
+                ):
+                    already_triaged.add(pr.number)
+                    break
+
+    return already_triaged
+
+
+_STALE_REVIEW_BATCH_SIZE = 10
+
+
+def _fetch_stale_review_data_batch(
+    token: str, github_repository: str, prs: list[PRData]
+) -> dict[int, list[StaleReviewInfo]]:
+    """Fetch review data for PRs to detect stale CHANGES_REQUESTED reviews.
+
+    For each PR, fetches the latest review per reviewer, the last commit date,
+    and recent comments. Returns a mapping from PR number to a list of
+    StaleReviewInfo for reviewers whose latest review is CHANGES_REQUESTED
+    and the author has pushed commits since that review.
+    """
+    if not prs:
+        return {}
+
+    owner, repo = github_repository.split("/", 1)
+    result: dict[int, list[StaleReviewInfo]] = {}
+
+    for chunk_start in range(0, len(prs), _STALE_REVIEW_BATCH_SIZE):
+        chunk = prs[chunk_start : chunk_start + _STALE_REVIEW_BATCH_SIZE]
+
+        pr_fields = []
+        for pr in chunk:
+            alias = f"pr{pr.number}"
+            pr_fields.append(
+                f"    {alias}: pullRequest(number: {pr.number}) {{\n"
+                f"      latestReviews(first: 20) {{\n"
+                f"        nodes {{\n"
+                f"          author {{ login }}\n"
+                f"          state\n"
+                f"          submittedAt\n"
+                f"        }}\n"
+                f"      }}\n"
+                f"      commits(last: 1) {{\n"
+                f"        nodes {{ commit {{ committedDate }} }}\n"
+                f"      }}\n"
+                f"      comments(last: 30) {{\n"
+                f"        nodes {{ author {{ login }} body createdAt }}\n"
+                f"      }}\n"
+                f"    }}"
+            )
+
+        query = (
+            f'query {{\n  repository(owner: "{owner}", name: "{repo}") {{\n'
+            + "\n".join(pr_fields)
+            + "\n  }\n}"
+        )
+
+        try:
+            data = _graphql_request(token, query, {})
+        except SystemExit:
+            continue
+
+        repo_data = data.get("repository", {})
+        for pr in chunk:
+            alias = f"pr{pr.number}"
+            pr_data = repo_data.get(alias) or {}
+
+            # Get last commit date
+            commits = pr_data.get("commits", {}).get("nodes", [])
+            last_commit_date = ""
+            if commits:
+                last_commit_date = commits[0].get("commit", 
{}).get("committedDate", "")
+
+            # Find reviewers whose latest review is CHANGES_REQUESTED
+            reviews = pr_data.get("latestReviews", {}).get("nodes", [])
+            changes_requested_reviews: list[tuple[str, str]] = []  # (login, 
submittedAt)
+            for review in reviews:
+                if review.get("state") != "CHANGES_REQUESTED":
+                    continue
+                reviewer = (review.get("author") or {}).get("login", "")
+                submitted_at = review.get("submittedAt", "")
+                if reviewer and submitted_at:
+                    changes_requested_reviews.append((reviewer, submitted_at))
+
+            if not changes_requested_reviews:
+                continue
+
+            # Check if there are commits after each review
+            stale_reviews: list[StaleReviewInfo] = []
+            comments = pr_data.get("comments", {}).get("nodes", [])
+
+            for reviewer_login, review_date in changes_requested_reviews:
+                # Only flag if the author pushed commits after the review
+                if not last_commit_date or last_commit_date <= review_date:
+                    continue
+
+                # Check if the author pinged the reviewer in comments after 
the review
+                author_pinged = False
+                for comment in comments:
+                    comment_author = (comment.get("author") or 
{}).get("login", "")
+                    comment_date = comment.get("createdAt", "")
+                    comment_body = comment.get("body", "")
+                    if (
+                        comment_author == pr.author_login
+                        and comment_date > review_date
+                        and f"@{reviewer_login}" in comment_body
+                    ):
+                        author_pinged = True
+                        break
+
+                stale_reviews.append(
+                    StaleReviewInfo(
+                        reviewer_login=reviewer_login,
+                        review_date=review_date,
+                        author_pinged_reviewer=author_pinged,
+                    )
+                )
+
+            if stale_reviews:
+                result[pr.number] = stale_reviews
+
+    return result
+
+
+def _build_review_nudge_comment(pr_author: str, stale_reviews: 
list[StaleReviewInfo]) -> str:
+    """Build a comment to nudge review follow-up on a PR with stale 
CHANGES_REQUESTED reviews.
+
+    If the author has pinged the reviewer(s), tag both and ask the reviewer(s) 
to re-check.
+    If not, tag only the author and suggest they ping the reviewer(s).
+    """
+    # Separate reviews by whether the author pinged
+    pinged = [r for r in stale_reviews if r.author_pinged_reviewer]
+    not_pinged = [r for r in stale_reviews if not r.author_pinged_reviewer]
+
+    parts: list[str] = []
+
+    if pinged:
+        reviewer_tags = " ".join(f"@{r.reviewer_login}" for r in pinged)
+        parts.append(
+            f"@{pr_author} {reviewer_tags} — This PR has new commits since the 
last review "
+            "requesting changes, and it looks like the author has followed up. 
"
+            "Could you take another look when you have a chance to see if the 
review "
+            "comments have been addressed? Thanks!"
+        )
+
+    if not_pinged:
+        reviewer_names = ", ".join(f"**{r.reviewer_login}**" for r in 
not_pinged)
+        parts.append(
+            f"@{pr_author} — This PR has new commits since the last review 
requesting changes "
+            f"from {reviewer_names}. If you believe you've addressed the 
feedback, "
+            "please ping the reviewer(s) to request a re-review. Thanks!"
+        )
+
+    return "\n\n".join(parts)
+
+
 def _fetch_prs_graphql(
     token: str,
     github_repository: str,
@@ -625,9 +877,13 @@ def _fetch_prs_graphql(
     updated_after: str | None = None,
     updated_before: str | None = None,
     review_requested: str | None = None,
-) -> list[PRData]:
-    """Fetch a single batch of matching PRs via GraphQL."""
-    query_parts = [f"repo:{github_repository}", "type:pr", "is:open", 
"draft:false"]
+    after_cursor: str | None = None,
+) -> tuple[list[PRData], bool, str | None]:
+    """Fetch a single batch of matching PRs via GraphQL.
+
+    Returns (prs, has_next_page, end_cursor).
+    """
+    query_parts = [f"repo:{github_repository}", "type:pr", "is:open"]
     if filter_user:
         query_parts.append(f"author:{filter_user}")
     if review_requested:
@@ -654,15 +910,24 @@ def _fetch_prs_graphql(
     sort_field, sort_direction = sort.rsplit("-", 1)
     search_query += f" sort:{sort_field}-{sort_direction}"
 
-    get_console().print(f"[info]Searching PRs: {search_query}[/]")
+    if not after_cursor:
+        get_console().print(f"[info]Searching PRs: {search_query}[/]")
+
+    variables: dict = {"query": search_query, "first": batch_size}
+    if after_cursor:
+        variables["after"] = after_cursor
 
-    data = _graphql_request(token, _SEARCH_PRS_QUERY, {"query": search_query, 
"first": batch_size})
+    data = _graphql_request(token, _SEARCH_PRS_QUERY, variables)
     search_data = data["search"]
+    page_info = search_data.get("pageInfo", {})
+    has_next_page = page_info.get("hasNextPage", False)
+    end_cursor = page_info.get("endCursor")
 
     get_console().print(
         f"[info]Found {search_data['issueCount']} matching "
         f"{'PRs' if search_data['issueCount'] != 1 else 'PR'}, "
-        f"fetched {len(search_data['nodes'])}.[/]"
+        f"fetched {len(search_data['nodes'])}"
+        f"{' (more available)' if has_next_page else ''}.[/]"
     )
 
     prs: list[PRData] = []
@@ -688,13 +953,14 @@ def _fetch_prs_graphql(
                 checks_state=checks_state,
                 failed_checks=[],
                 commits_behind=0,
+                is_draft=bool(node.get("isDraft", False)),
                 mergeable=node.get("mergeable", "UNKNOWN"),
                 labels=[lbl["name"] for lbl in (node.get("labels") or 
{}).get("nodes", []) if lbl],
                 unresolved_review_comments=0,
             )
         )
 
-    return prs
+    return prs, has_next_page, end_cursor
 
 
 def _fetch_single_pr_graphql(token: str, github_repository: str, pr_number: 
int) -> PRData:
@@ -726,6 +992,7 @@ def _fetch_single_pr_graphql(token: str, github_repository: 
str, pr_number: int)
         checks_state=checks_state,
         failed_checks=[],
         commits_behind=0,
+        is_draft=bool(node.get("isDraft", False)),
         mergeable=node.get("mergeable", "UNKNOWN"),
         labels=[lbl["name"] for lbl in (node.get("labels") or {}).get("nodes", 
[]) if lbl],
         unresolved_review_comments=0,
@@ -825,6 +1092,7 @@ def _fetch_author_profile(token: str, login: str, 
github_repository: str) -> dic
     profile = {
         "login": login,
         "account_age": account_age,
+        "created_at": created_at,
         "repo_total_prs": data.get("repoAll", {}).get("issueCount", 0),
         "repo_merged_prs": data.get("repoMerged", {}).get("issueCount", 0),
         "repo_closed_prs": data.get("repoClosed", {}).get("issueCount", 0),
@@ -852,6 +1120,15 @@ def _convert_pr_to_draft(token: str, node_id: str) -> 
bool:
         return False
 
 
+def _mark_pr_ready_for_review(token: str, node_id: str) -> bool:
+    """Mark a draft PR as ready for review using GitHub GraphQL API."""
+    try:
+        _graphql_request(token, _MARK_READY_FOR_REVIEW_MUTATION, {"prId": 
node_id})
+        return True
+    except SystemExit:
+        return False
+
+
 def _close_pr(token: str, node_id: str) -> bool:
     """Close a PR using GitHub GraphQL API."""
     try:
@@ -1067,8 +1344,17 @@ def _compute_default_action(
     if count > 3:
         reason_parts.append(f"author has {count} flagged {'PRs' if count != 1 
else 'PR'}")
         action = TriageAction.CLOSE
-    elif not has_ci_failures and (has_conflicts or has_unresolved_comments):
-        # CI passes, no LLM issues — only conflicts or unresolved comments; 
just add a comment
+    elif (
+        not has_conflicts
+        and has_ci_failures
+        and failed_count <= 2
+        and not has_unresolved_comments
+        and pr.commits_behind <= 50
+    ):
+        # Only 1-2 CI failures, no conflicts, no unresolved comments, not too 
far behind — suggest rerun
+        action = TriageAction.RERUN
+    elif not has_ci_failures and not has_conflicts and has_unresolved_comments:
+        # CI passes, no conflicts, no LLM issues — only unresolved review 
comments; just add a comment
         action = TriageAction.COMMENT
     else:
         action = TriageAction.DRAFT
@@ -1077,9 +1363,10 @@ def _compute_default_action(
     reason = reason[0].upper() + reason[1:]
     action_label = {
         TriageAction.DRAFT: "draft",
-        TriageAction.COMMENT: "add comment",
+        TriageAction.COMMENT: "comment",
         TriageAction.CLOSE: "close",
-    }[action]
+        TriageAction.RERUN: "rerun checks",
+    }.get(action, str(action))
     return action, f"{reason} — suggesting {action_label}"
 
 
@@ -1096,6 +1383,39 @@ def _fmt_duration(seconds: float) -> str:
     return f"{hours}h {mins:02d}m {secs:02d}s"
 
 
+def _select_violations(violations: list) -> list | None:
+    """Prompt the user to select which violations to include in the comment.
+
+    Returns the selected subset, or the original list if the user accepts all.
+    Returns None if there are no violations to select from.
+    """
+    if len(violations) <= 1:
+        return violations
+
+    console = get_console()
+    console.print("\n  [bold]Select which issues to include in the 
comment:[/]")
+    for i, v in enumerate(violations, 1):
+        color = "red" if v.severity == "error" else "yellow"
+        console.print(f"    [{color}]{i}.[/{color}] {v.category}: 
{v.explanation}")
+
+    console.print("    [dim]Enter numbers (e.g. 1,3), 'a' for all, or 's' to 
skip comment[/]")
+    user_input = input("  Include [A/numbers/s]: ").strip()
+
+    if not user_input or user_input.lower() == "a":
+        return violations
+    if user_input.lower() == "s":
+        return []
+
+    selected = []
+    for raw_num in user_input.split(","):
+        stripped = raw_num.strip()
+        if stripped.isdigit():
+            idx = int(stripped) - 1
+            if 0 <= idx < len(violations):
+                selected.append(violations[idx])
+    return selected if selected else violations
+
+
 def _pr_link(pr: PRData) -> str:
     """Return a Rich-markup clickable link for a PR: 
[link=url]#number[/link]."""
     return f"[link={pr.url}]#{pr.number}[/link]"
@@ -1109,6 +1429,8 @@ def _display_pr_info_panels(pr: PRData, author_profile: 
dict | None):
     console.print()
 
     status_info = ""
+    if pr.is_draft:
+        status_info += "\n[yellow]Draft PR[/]"
     if pr.commits_behind > 0:
         status_info += (
             f"\n[yellow]{pr.commits_behind} commit{'s' if pr.commits_behind != 
1 else ''} "
@@ -1127,17 +1449,47 @@ def _display_pr_info_panels(pr: PRData, author_profile: 
dict | None):
 
     if author_profile:
         login = author_profile["login"]
+
+        # Color account age: red if < 1 week, yellow if < 1 month
+        account_age_text = author_profile["account_age"]
+        created_at_raw = author_profile.get("created_at", "unknown")
+        if created_at_raw != "unknown":
+            from datetime import datetime, timezone
+
+            try:
+                created_dt = 
datetime.fromisoformat(created_at_raw.replace("Z", "+00:00"))
+                age_days = (datetime.now(timezone.utc) - created_dt).days
+                if age_days < 7:
+                    account_age_text = f"[red]{account_age_text}[/]"
+                elif age_days < 30:
+                    account_age_text = f"[yellow]{account_age_text}[/]"
+                elif age_days >= 60 and author_profile.get("repo_merged_prs", 
0) > 0:
+                    account_age_text = f"[green]{account_age_text}[/]"
+            except (ValueError, TypeError):
+                pass
+
+        def _pr_line(label: str, total: int, merged: int, closed: int) -> str:
+            """Format a PR stats line with colors."""
+            merged_text = f"[green]{merged} merged[/]"
+            closed_text = f"{closed} closed (unmerged)"
+            line = f"{label}: {total} total, {merged_text}, {closed_text}"
+            if closed > merged:
+                line = f"[red]{label}: {total} total, {merged} merged, 
{closed_text}[/]"
+            return line
+
         lines = [
-            f"Account created: {author_profile['account_age']}",
-            (
-                f"PRs in this repo: {author_profile['repo_total_prs']} total, "
-                f"{author_profile['repo_merged_prs']} merged, "
-                f"{author_profile['repo_closed_prs']} closed (unmerged)"
+            f"Account created: {account_age_text}",
+            _pr_line(
+                "PRs in this repo",
+                author_profile["repo_total_prs"],
+                author_profile["repo_merged_prs"],
+                author_profile["repo_closed_prs"],
             ),
-            (
-                f"PRs across GitHub: {author_profile['global_total_prs']} 
total, "
-                f"{author_profile['global_merged_prs']} merged, "
-                f"{author_profile['global_closed_prs']} closed (unmerged)"
+            _pr_line(
+                "PRs across GitHub",
+                author_profile["global_total_prs"],
+                author_profile["global_merged_prs"],
+                author_profile["global_closed_prs"],
             ),
         ]
 
@@ -1161,8 +1513,8 @@ def _display_pr_info_panels(pr: PRData, author_profile: 
dict | None):
         console.print(Panel("\n".join(lines), title=f"Author: {author_link}", 
border_style="yellow"))
 
 
-def _display_pr_panel(pr: PRData, author_profile: dict | None, assessment, 
comment: str):
-    """Display Rich panels with PR details, author info, violations, and 
proposed comment."""
+def _display_pr_panel(pr: PRData, author_profile: dict | None, assessment):
+    """Display Rich panels with PR details, author info, and violations."""
     console = get_console()
     _display_pr_info_panels(pr, author_profile)
 
@@ -1174,8 +1526,6 @@ def _display_pr_panel(pr: PRData, author_profile: dict | 
None, assessment, comme
         Panel("\n".join(violation_lines), title=f"Assessment: 
{assessment.summary}", border_style="red")
     )
 
-    console.print(Panel(comment, title="Proposed comment", 
border_style="green"))
-
 
 def _display_workflow_approval_panel(pr: PRData, author_profile: dict | None, 
pending_runs: list[dict]):
     """Display Rich panels for a PR needing workflow approval."""
@@ -1199,6 +1549,8 @@ def _display_workflow_approval_panel(pr: PRData, 
author_profile: dict | None, pe
     else:
         info_text = "[bright_cyan]No test workflows have run on this 
PR.[/]\n\n"
 
+    if pr.is_draft:
+        info_text += "[yellow]This PR is a draft.[/]\n"
     info_text += (
         "Please review the PR changes before approving:\n"
         f"  [link={pr.url}/files]View changes on GitHub[/link]"
@@ -1329,6 +1681,8 @@ class TriageStats:
     total_commented: int = 0
     total_closed: int = 0
     total_ready: int = 0
+    total_rerun: int = 0
+    total_review_nudges: int = 0
     total_skipped_action: int = 0
     total_workflows_approved: int = 0
     quit_early: bool = False
@@ -1372,6 +1726,46 @@ class TriageContext:
             get_console().print(progress)
 
 
+def _confirm_action(pr: PRData, description: str, forced_answer: str | 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.
+    """
+    import os
+
+    from airflow_breeze.utils.confirm import _read_char
+    from airflow_breeze.utils.shared_options import get_forced_answer
+
+    force = forced_answer or get_forced_answer() or os.environ.get("ANSWER")
+    if force:
+        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] "
+    get_console().print(prompt, end="")
+
+    try:
+        ch = _read_char()
+    except (KeyboardInterrupt, EOFError):
+        get_console().print()
+        get_console().print(f"  [info]Cancelled — no changes made to PR 
{_pr_link(pr)}.[/]")
+        return False
+
+    # Ignore multi-byte escape sequences (arrow keys, etc.)
+    if len(ch) > 1:
+        get_console().print()
+        get_console().print(f"  [info]Cancelled — no changes made to PR 
{_pr_link(pr)}.[/]")
+        return False
+
+    # Echo the character and move to next line
+    get_console().print(ch)
+
+    if ch.upper() in ("Y", "\r", "\n", ""):
+        return True
+    get_console().print(f"  [info]Cancelled — no changes made to PR 
{_pr_link(pr)}.[/]")
+    return False
+
+
 def _execute_triage_action(
     ctx: TriageContext,
     pr: PRData,
@@ -1390,6 +1784,9 @@ def _execute_triage_action(
         return
 
     if action == TriageAction.READY:
+        if not _confirm_action(pr, "Add 'ready for maintainer review' label", 
ctx.answer_triage):
+            stats.total_skipped_action += 1
+            return
         get_console().print(
             f"  [info]Marking PR {_pr_link(pr)} as ready — adding 
'{_READY_FOR_REVIEW_LABEL}' label.[/]"
         )
@@ -1402,7 +1799,48 @@ def _execute_triage_action(
             get_console().print(f"  [warning]Failed to add label to PR 
{_pr_link(pr)}.[/]")
         return
 
+    if action == TriageAction.RERUN:
+        if pr.head_sha and pr.failed_checks:
+            get_console().print(
+                f"  Rerunning {len(pr.failed_checks)} failed "
+                f"{'checks' if len(pr.failed_checks) != 1 else 'check'} for PR 
{_pr_link(pr)}..."
+            )
+            rerun_count = _rerun_failed_workflow_runs(
+                ctx.token, ctx.github_repository, pr.head_sha, pr.failed_checks
+            )
+            if rerun_count:
+                get_console().print(
+                    f"  [success]Rerun triggered for {rerun_count} workflow "
+                    f"{'runs' if rerun_count != 1 else 'run'} on PR 
{_pr_link(pr)}.[/]"
+                )
+                stats.total_rerun += 1
+            else:
+                # No completed failed runs to rerun — check if workflows are 
still running
+                get_console().print(
+                    f"  [warning]No completed failed runs found for PR 
{_pr_link(pr)}. "
+                    f"Checking for in-progress workflows...[/]"
+                )
+                restarted = _cancel_and_rerun_in_progress_workflows(
+                    ctx.token, ctx.github_repository, pr.head_sha
+                )
+                if restarted:
+                    get_console().print(
+                        f"  [success]Cancelled and restarted {restarted} 
workflow "
+                        f"{'runs' if restarted != 1 else 'run'} on PR 
{_pr_link(pr)}.[/]"
+                    )
+                    stats.total_rerun += 1
+                else:
+                    get_console().print(
+                        f"  [warning]Could not rerun any workflow runs for PR 
{_pr_link(pr)}.[/]"
+                    )
+        else:
+            get_console().print(f"  [warning]No failed checks to rerun for PR 
{_pr_link(pr)}.[/]")
+        return
+
     if action == TriageAction.COMMENT:
+        if not _confirm_action(pr, "Post comment", ctx.answer_triage):
+            stats.total_skipped_action += 1
+            return
         text = comment_only_text or draft_comment
         get_console().print(f"  Posting comment on PR {_pr_link(pr)}...")
         if _post_comment(ctx.token, pr.node_id, text):
@@ -1413,6 +1851,9 @@ def _execute_triage_action(
         return
 
     if action == TriageAction.DRAFT:
+        if not _confirm_action(pr, "Convert to draft and post comment", 
ctx.answer_triage):
+            stats.total_skipped_action += 1
+            return
         get_console().print(f"  Converting PR {_pr_link(pr)} to draft...")
         if _convert_pr_to_draft(ctx.token, pr.node_id):
             get_console().print(f"  [success]PR {_pr_link(pr)} converted to 
draft.[/]")
@@ -1428,6 +1869,9 @@ def _execute_triage_action(
         return
 
     if action == TriageAction.CLOSE:
+        if not _confirm_action(pr, "Close PR and post comment", 
ctx.answer_triage):
+            stats.total_skipped_action += 1
+            return
         get_console().print(f"  Closing PR {_pr_link(pr)}...")
         if _close_pr(ctx.token, pr.node_id):
             get_console().print(f"  [success]PR {_pr_link(pr)} closed.[/]")
@@ -1456,37 +1900,27 @@ def _prompt_and_execute_flagged_pr(
     """Display a flagged PR panel, prompt user for action, and execute it. 
Mutates ctx.stats."""
     author_profile = _fetch_author_profile(ctx.token, pr.author_login, 
ctx.github_repository)
 
-    draft_comment = _build_comment(
-        pr.author_login, assessment.violations, pr.number, pr.commits_behind, 
pr.base_ref
-    )
-    close_comment = _build_close_comment(
-        pr.author_login,
-        assessment.violations,
-        pr.number,
-        ctx.author_flagged_count.get(pr.author_login, 0),
-    )
-    if comment_only_text is None:
-        comment_only_text = _build_comment(
-            pr.author_login,
-            assessment.violations,
-            pr.number,
-            pr.commits_behind,
-            pr.base_ref,
-            comment_only=True,
-        )
-    _display_pr_panel(pr, author_profile, assessment, draft_comment)
+    _display_pr_panel(pr, author_profile, assessment)
 
     default_action, reason = _compute_default_action(pr, assessment, 
ctx.author_flagged_count)
-    if default_action == TriageAction.CLOSE:
-        get_console().print(Panel(close_comment, title="Proposed close 
comment", border_style="red"))
+    # If PR is already a draft, don't offer converting to draft — use comment 
instead
+    exclude_actions: set[TriageAction] | None = None
+    if pr.is_draft and default_action == TriageAction.DRAFT:
+        default_action = TriageAction.COMMENT
+        reason = reason.replace("draft", "comment (already draft)")
+        exclude_actions = {TriageAction.DRAFT}
+    elif pr.is_draft:
+        exclude_actions = {TriageAction.DRAFT}
+
     get_console().print(f"  [bold]{reason}[/]")
 
     if ctx.dry_run:
         action_label = {
             TriageAction.DRAFT: "draft",
-            TriageAction.COMMENT: "add comment",
+            TriageAction.COMMENT: "comment",
             TriageAction.CLOSE: "close",
-            TriageAction.READY: "ready",
+            TriageAction.RERUN: "rerun checks",
+            TriageAction.READY: "mark as ready",
             TriageAction.SKIP: "skip",
         }.get(default_action, str(default_action))
         get_console().print(f"[warning]Dry run — would default to: 
{action_label}[/]")
@@ -1496,6 +1930,8 @@ def _prompt_and_execute_flagged_pr(
         f"Action for PR {_pr_link(pr)}?",
         default=default_action,
         forced_answer=ctx.answer_triage,
+        exclude=exclude_actions,
+        pr_url=pr.url,
     )
 
     if action == TriageAction.QUIT:
@@ -1503,6 +1939,43 @@ def _prompt_and_execute_flagged_pr(
         ctx.stats.quit_early = True
         return
 
+    # For actions that post comments, let the user select violations and 
preview the comment
+    draft_comment = ""
+    close_comment = ""
+    if action in (TriageAction.DRAFT, TriageAction.COMMENT, 
TriageAction.CLOSE):
+        selected = _select_violations(assessment.violations)
+        if selected is not None and len(selected) == 0:
+            get_console().print("  [info]No violations selected — 
skipping.[/]")
+            action = TriageAction.SKIP
+        else:
+            violations = selected if selected is not None else 
assessment.violations
+            draft_comment = _build_comment(
+                pr.author_login, violations, pr.number, pr.commits_behind, 
pr.base_ref
+            )
+            close_comment = _build_close_comment(
+                pr.author_login,
+                violations,
+                pr.number,
+                ctx.author_flagged_count.get(pr.author_login, 0),
+            )
+            comment_only_text = _build_comment(
+                pr.author_login,
+                violations,
+                pr.number,
+                pr.commits_behind,
+                pr.base_ref,
+                comment_only=True,
+            )
+            # Show the final comment that will be posted
+            if action == TriageAction.CLOSE:
+                get_console().print(Panel(close_comment, title="Comment to be 
posted", border_style="red"))
+            elif action == TriageAction.COMMENT:
+                get_console().print(
+                    Panel(comment_only_text, title="Comment to be posted", 
border_style="green")
+                )
+            else:
+                get_console().print(Panel(draft_comment, title="Comment to be 
posted", border_style="green"))
+
     _execute_triage_action(
         ctx,
         pr,
@@ -1525,6 +1998,7 @@ def _display_pr_overview_table(all_prs: list[PRData]) -> 
None:
     pr_table.add_column("Behind", justify="right")
     pr_table.add_column("Conflicts")
     pr_table.add_column("CI Status")
+    pr_table.add_column("Workflows")
     for pr in non_collab_prs:
         if pr.checks_state == "FAILURE":
             ci_status = "[red]Failing[/]"
@@ -1542,11 +2016,31 @@ def _display_pr_overview_table(all_prs: list[PRData]) 
-> None:
         else:
             conflicts_text = "[green]No[/]"
 
+        # Workflow status
+        if pr.checks_state == "NOT_RUN":
+            workflows_text = "[bright_cyan]Needs approval[/]"
+        elif pr.checks_state == "PENDING":
+            workflows_text = "[yellow]Running[/]"
+        else:
+            workflows_text = "[green]Done[/]"
+
         has_issues = pr.checks_state == "FAILURE" or pr.mergeable == 
"CONFLICTING"
-        overall = "[red]Flag[/]" if has_issues else "[green]OK[/]"
+        if pr.is_draft:
+            overall = "[yellow]Draft[/]"
+        elif has_issues:
+            overall = "[red]Flag[/]"
+        else:
+            overall = "[green]OK[/]"
 
         pr_table.add_row(
-            _pr_link(pr), pr.title[:50], pr.author_login, overall, 
behind_text, conflicts_text, ci_status
+            _pr_link(pr),
+            pr.title[:50],
+            pr.author_login,
+            overall,
+            behind_text,
+            conflicts_text,
+            ci_status,
+            workflows_text,
         )
     get_console().print(pr_table)
     if collab_count:
@@ -1560,20 +2054,27 @@ def _filter_candidate_prs(
     all_prs: list[PRData],
     *,
     include_collaborators: bool,
+    include_drafts: bool,
     checks_state: str,
     min_commits_behind: int,
     max_num: int,
-) -> tuple[list[PRData], int, int, int]:
-    """Filter PRs to candidates. Returns (candidates, skipped_collaborator, 
skipped_bot, skipped_accepted)."""
+) -> tuple[list[PRData], list[PRData], int, int, int]:
+    """Filter PRs to candidates. Returns (candidates, accepted_prs, 
skipped_collaborator, skipped_bot, skipped_accepted)."""
     candidate_prs: list[PRData] = []
+    accepted_prs: list[PRData] = []
     total_skipped_collaborator = 0
     total_skipped_bot = 0
     total_skipped_accepted = 0
     total_skipped_checks_state = 0
     total_skipped_commits_behind = 0
+    total_skipped_drafts = 0
     verbose = get_verbose()
     for pr in all_prs:
-        if not include_collaborators and pr.author_association in 
_COLLABORATOR_ASSOCIATIONS:
+        if not include_drafts and pr.is_draft:
+            total_skipped_drafts += 1
+            if verbose:
+                get_console().print(f"  [dim]Skipping PR {_pr_link(pr)} — 
draft PR[/]")
+        elif not include_collaborators and pr.author_association in 
_COLLABORATOR_ASSOCIATIONS:
             total_skipped_collaborator += 1
             if verbose:
                 get_console().print(
@@ -1586,6 +2087,7 @@ def _filter_candidate_prs(
                 get_console().print(f"  [dim]Skipping PR {_pr_link(pr)} — bot 
account {pr.author_login}[/]")
         elif _READY_FOR_REVIEW_LABEL in pr.labels:
             total_skipped_accepted += 1
+            accepted_prs.append(pr)
             if verbose:
                 get_console().print(
                     f"  [dim]Skipping PR {_pr_link(pr)} — already has 
'{_READY_FOR_REVIEW_LABEL}' label[/]"
@@ -1615,6 +2117,8 @@ def _filter_candidate_prs(
             f"{total_skipped_collaborator} "
             f"{'collaborators' if total_skipped_collaborator != 1 else 
'collaborator'}"
         )
+    if total_skipped_drafts:
+        skipped_parts.append(f"{total_skipped_drafts} {'drafts' if 
total_skipped_drafts != 1 else 'draft'}")
     if total_skipped_bot:
         skipped_parts.append(f"{total_skipped_bot} {'bots' if 
total_skipped_bot != 1 else 'bot'}")
     if total_skipped_accepted:
@@ -1630,7 +2134,7 @@ def _filter_candidate_prs(
         f"assessing {len(candidate_prs)} {'PRs' if len(candidate_prs) != 1 
else 'PR'}"
         f"{f' (capped at {max_num})' if max_num else ''}...[/]\n"
     )
-    return candidate_prs, total_skipped_collaborator, total_skipped_bot, 
total_skipped_accepted
+    return candidate_prs, accepted_prs, total_skipped_collaborator, 
total_skipped_bot, total_skipped_accepted
 
 
 def _enrich_candidate_details(
@@ -1684,9 +2188,11 @@ def _review_workflow_approval_prs(ctx: TriageContext, 
pending_approval: list[PRD
         return
 
     pending_approval.sort(key=lambda p: (p.author_login.lower(), p.number))
+    draft_count = sum(1 for pr in pending_approval if pr.is_draft)
+    draft_note = f" ({draft_count} draft)" if draft_count else ""
     get_console().print(
-        f"\n[info]{len(pending_approval)} {'PRs have' if len(pending_approval) 
!= 1 else 'PR has'} "
-        f"no test workflows run — review and approve workflow runs"
+        f"\n[info]{len(pending_approval)} {'PRs' if len(pending_approval) != 1 
else 'PR'}{draft_note} "
+        f"need workflow approval — review and approve workflow runs"
         f"{' (LLM assessments running in background)' if ctx.llm_future_to_pr 
else ''}:[/]\n"
     )
     for pr in pending_approval:
@@ -1694,6 +2200,7 @@ def _review_workflow_approval_prs(ctx: TriageContext, 
pending_approval: list[PRD
 
         author_profile = _fetch_author_profile(ctx.token, pr.author_login, 
ctx.github_repository)
         pending_runs = _find_pending_workflow_runs(ctx.token, 
ctx.github_repository, pr.head_sha)
+
         _display_workflow_approval_panel(pr, author_profile, pending_runs)
 
         # If author exceeds the close threshold, suggest closing instead of 
approving
@@ -1715,6 +2222,8 @@ def _review_workflow_approval_prs(ctx: TriageContext, 
pending_approval: list[PRD
                 f"Action for PR {_pr_link(pr)}?",
                 default=TriageAction.CLOSE,
                 forced_answer=ctx.answer_triage,
+                exclude={TriageAction.DRAFT} if pr.is_draft else None,
+                pr_url=pr.url,
             )
             if action == TriageAction.QUIT:
                 get_console().print("[warning]Quitting.[/]")
@@ -1765,6 +2274,18 @@ def _review_workflow_approval_prs(ctx: TriageContext, 
pending_approval: list[PRD
                     border_style="bright_cyan",
                 )
             )
+
+            # Warn about changes to sensitive directories (.github/, scripts/)
+            sensitive_files = _detect_sensitive_file_changes(diff_text)
+            if sensitive_files:
+                get_console().print()
+                get_console().print(
+                    "[bold red]WARNING: This PR contains changes to sensitive 
files "
+                    "— please review carefully![/]"
+                )
+                for f in sensitive_files:
+                    get_console().print(f"  [bold red]  - {f}[/]")
+                get_console().print()
         else:
             get_console().print(
                 f"  [warning]Could not fetch diff for PR {_pr_link(pr)}. "
@@ -1834,6 +2355,68 @@ def _review_workflow_approval_prs(ctx: TriageContext, 
pending_approval: list[PRD
             ctx.stats.total_workflows_approved += 1
         else:
             get_console().print(f"  [error]Failed to approve workflow runs for 
PR {_pr_link(pr)}.[/]")
+            # Approval failed (likely 403 — runs are too old). Suggest 
converting to draft
+            # with a rebase comment so the author pushes fresh commits.
+            if pr.is_draft:
+                # Already a draft — just add a comment, no need to convert
+                rebase_comment = (
+                    f"@{pr.author_login} This PR has workflow runs awaiting 
approval that could "
+                    f"not be approved (they are likely too old). The PR is "
+                    f"**{pr.commits_behind} commits behind 
`{pr.base_ref}`**.\n\n"
+                    "Please **rebase** your branch onto the latest base branch 
and push again "
+                    "so that fresh CI workflows can run.\n\n"
+                    "If you need help rebasing, see our "
+                    "[contributor 
guide](https://github.com/apache/airflow/blob/main/";
+                    "contributing-docs/05_pull_requests.rst)."
+                )
+                default_action = TriageAction.COMMENT
+                exclude_actions = {TriageAction.DRAFT}
+            else:
+                rebase_comment = (
+                    f"@{pr.author_login} This PR has workflow runs awaiting 
approval that could "
+                    f"not be approved (they are likely too old). The PR is "
+                    f"**{pr.commits_behind} commits behind 
`{pr.base_ref}`**.\n\n"
+                    "Please **rebase** your branch onto the latest base branch 
and push again "
+                    "so that fresh CI workflows can run.\n\n"
+                    "If you need help rebasing, see our "
+                    "[contributor 
guide](https://github.com/apache/airflow/blob/main/";
+                    "contributing-docs/05_pull_requests.rst).\n\n"
+                    "Converting this PR to **draft** until it is rebased."
+                )
+                default_action = TriageAction.DRAFT
+                exclude_actions = None
+            get_console().print(Panel(rebase_comment, title="Proposed rebase 
comment", border_style="yellow"))
+            action = prompt_triage_action(
+                f"Action for PR {_pr_link(pr)}?",
+                default=default_action,
+                forced_answer=ctx.answer_triage,
+                exclude=exclude_actions,
+                pr_url=pr.url,
+            )
+            if action == TriageAction.QUIT:
+                get_console().print("[warning]Quitting.[/]")
+                ctx.stats.quit_early = True
+                return
+            if action == TriageAction.SKIP:
+                _execute_triage_action(ctx, pr, TriageAction.SKIP, 
draft_comment="", close_comment="")
+            elif action == TriageAction.COMMENT:
+                _execute_triage_action(
+                    ctx,
+                    pr,
+                    TriageAction.COMMENT,
+                    draft_comment="",
+                    close_comment="",
+                    comment_only_text=rebase_comment,
+                )
+            elif action == TriageAction.DRAFT:
+                _execute_triage_action(
+                    ctx, pr, TriageAction.DRAFT, draft_comment=rebase_comment, 
close_comment=""
+                )
+            elif action == TriageAction.CLOSE:
+                close_comment = _build_close_comment(pr.author_login, [], 
pr.number, 0)
+                _execute_triage_action(
+                    ctx, pr, TriageAction.CLOSE, draft_comment="", 
close_comment=close_comment
+                )
 
 
 def _review_deterministic_flagged_prs(
@@ -1951,6 +2534,7 @@ def _review_passing_prs(ctx: TriageContext, passing_prs: 
list[PRData]) -> None:
     for pr in passing_prs:
         author_profile = _fetch_author_profile(ctx.token, pr.author_login, 
ctx.github_repository)
         _display_pr_info_panels(pr, author_profile)
+        get_console().print("[success]This looks like a PR that is ready for 
review.[/]")
 
         if ctx.dry_run:
             get_console().print("[warning]Dry run — skipping.[/]")
@@ -1958,8 +2542,10 @@ def _review_passing_prs(ctx: TriageContext, passing_prs: 
list[PRData]) -> None:
 
         action = prompt_triage_action(
             f"Action for PR {_pr_link(pr)}?",
-            default=TriageAction.SKIP,
+            default=TriageAction.READY,
             forced_answer=ctx.answer_triage,
+            exclude={TriageAction.DRAFT} if pr.is_draft else None,
+            pr_url=pr.url,
         )
 
         if action == TriageAction.QUIT:
@@ -1968,16 +2554,152 @@ def _review_passing_prs(ctx: TriageContext, 
passing_prs: list[PRData]) -> None:
             return
 
         if action == TriageAction.READY:
-            get_console().print(
-                f"  [info]Marking PR {_pr_link(pr)} as ready — adding 
'{_READY_FOR_REVIEW_LABEL}' label.[/]"
-            )
-            if _add_label(ctx.token, ctx.github_repository, pr.node_id, 
_READY_FOR_REVIEW_LABEL):
+            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 pr.is_draft:
+                    # Undraft the PR first
+                    if _mark_pr_ready_for_review(ctx.token, pr.node_id):
+                        get_console().print(
+                            f"  [success]PR {_pr_link(pr)} marked as ready for 
review (undrafted).[/]"
+                        )
+                    else:
+                        get_console().print(f"  [warning]Failed to undraft PR 
{_pr_link(pr)}.[/]")
+                    # Post a polite comment
+                    comment = (
+                        "This PR looks like it's ready for review, so I'm 
marking it as such "
+                        "and adding the `ready for maintainer review` 
label.\n\n"
+                        "As a friendly reminder — next time please mark your 
PR as "
+                        "ready for review yourself when you're done working on 
it. "
+                        "This helps maintainers find PRs that need attention 
more quickly. "
+                        "Thank you! \U0001f64f"
+                    )
+                    _post_comment(ctx.token, pr.node_id, comment)
                 get_console().print(
-                    f"  [success]Label '{_READY_FOR_REVIEW_LABEL}' added to PR 
{_pr_link(pr)}.[/]"
+                    f"  [info]Adding '{_READY_FOR_REVIEW_LABEL}' label to PR 
{_pr_link(pr)}.[/]"
                 )
-                ctx.stats.total_ready += 1
+                if _add_label(ctx.token, ctx.github_repository, pr.node_id, 
_READY_FOR_REVIEW_LABEL):
+                    get_console().print(
+                        f"  [success]Label '{_READY_FOR_REVIEW_LABEL}' added 
to PR {_pr_link(pr)}.[/]"
+                    )
+                    ctx.stats.total_ready += 1
+                else:
+                    get_console().print(f"  [warning]Failed to add label to PR 
{_pr_link(pr)}.[/]")
+            else:
+                ctx.stats.total_skipped_action += 1
+        else:
+            get_console().print(f"  [info]Skipping PR {_pr_link(pr)} — no 
action taken.[/]")
+            ctx.stats.total_skipped_action += 1
+
+
+def _review_stale_review_requests(
+    ctx: TriageContext,
+    accepted_prs: list[PRData],
+) -> None:
+    """Present accepted PRs with stale CHANGES_REQUESTED reviews for follow-up 
nudging.
+
+    Finds PRs that have the 'ready for maintainer review' label, pass all 
checks,
+    and have CHANGES_REQUESTED reviews with commits pushed after the review.
+    Proposes a comment to nudge the author or reviewer to follow up.
+    """
+    if ctx.stats.quit_early or not accepted_prs:
+        return
+
+    # Only consider PRs that pass all checks
+    passing_accepted = [pr for pr in accepted_prs if pr.checks_state == 
"SUCCESS"]
+    if not passing_accepted:
+        return
+
+    get_console().print(
+        f"[info]Checking {len(passing_accepted)} accepted "
+        f"{'PRs' if len(passing_accepted) != 1 else 'PR'} for stale review 
requests...[/]"
+    )
+    stale_data = _fetch_stale_review_data_batch(ctx.token, 
ctx.github_repository, passing_accepted)
+    if not stale_data:
+        get_console().print("  [dim]No stale review requests found.[/]")
+        return
+
+    stale_prs = [pr for pr in passing_accepted if pr.number in stale_data]
+    stale_prs.sort(key=lambda p: (p.author_login.lower(), p.number))
+
+    get_console().print(
+        f"\n[info]{len(stale_prs)} accepted "
+        f"{'PRs have' if len(stale_prs) != 1 else 'PR has'} stale review 
requests "
+        f"with commits pushed after the review — review to nudge 
follow-up:[/]\n"
+    )
+
+    for pr in stale_prs:
+        if ctx.stats.quit_early:
+            break
+
+        reviews = stale_data[pr.number]
+        author_profile = _fetch_author_profile(ctx.token, pr.author_login, 
ctx.github_repository)
+        _display_pr_info_panels(pr, author_profile)
+
+        # Show review info
+        console = get_console()
+        review_lines = []
+        for r in reviews:
+            ping_status = (
+                "[green]author pinged reviewer[/]"
+                if r.author_pinged_reviewer
+                else "[yellow]no follow-up ping from author[/]"
+            )
+            review_lines.append(
+                f"  Reviewer: [bold]{r.reviewer_login}[/] — "
+                f"requested changes: {_human_readable_age(r.review_date)} — 
{ping_status}"
+            )
+        console.print(
+            Panel(
+                "\n".join(review_lines),
+                title="Stale Review Requests",
+                border_style="yellow",
+            )
+        )
+
+        # Build and show proposed comment
+        comment = _build_review_nudge_comment(pr.author_login, reviews)
+        console.print(Panel(comment, title="Proposed comment", 
border_style="green"))
+
+        if ctx.dry_run:
+            get_console().print("[warning]Dry run — skipping.[/]")
+            continue
+
+        action = prompt_triage_action(
+            f"Action for PR {_pr_link(pr)}?",
+            default=TriageAction.COMMENT,
+            forced_answer=ctx.answer_triage,
+            exclude={TriageAction.DRAFT, TriageAction.CLOSE, 
TriageAction.RERUN},
+            pr_url=pr.url,
+        )
+
+        if action == TriageAction.QUIT:
+            get_console().print("[warning]Quitting.[/]")
+            ctx.stats.quit_early = True
+            return
+
+        if action == TriageAction.COMMENT:
+            if _confirm_action(pr, "Post review nudge comment", 
ctx.answer_triage):
+                if _post_comment(ctx.token, pr.node_id, comment):
+                    get_console().print(f"  [success]Comment posted on PR 
{_pr_link(pr)}.[/]")
+                    ctx.stats.total_review_nudges += 1
+                else:
+                    get_console().print(f"  [error]Failed to post comment on 
PR {_pr_link(pr)}.[/]")
+            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 _add_label(ctx.token, ctx.github_repository, pr.node_id, 
_READY_FOR_REVIEW_LABEL):
+                    get_console().print(
+                        f"  [success]Label '{_READY_FOR_REVIEW_LABEL}' added 
to PR {_pr_link(pr)}.[/]"
+                    )
+                    ctx.stats.total_ready += 1
+                else:
+                    get_console().print(f"  [warning]Failed to add label to PR 
{_pr_link(pr)}.[/]")
             else:
-                get_console().print(f"  [warning]Failed to add label to PR 
{_pr_link(pr)}.[/]")
+                ctx.stats.total_skipped_action += 1
         else:
             get_console().print(f"  [info]Skipping PR {_pr_link(pr)} — no 
action taken.[/]")
             ctx.stats.total_skipped_action += 1
@@ -1988,6 +2710,9 @@ def _display_triage_summary(
     candidate_prs: list[PRData],
     passing_prs: list[PRData],
     pending_approval: list[PRData],
+    workflows_in_progress: list[PRData],
+    skipped_drafts: list[PRData],
+    already_triaged: list[PRData],
     stats: TriageStats,
     *,
     total_deterministic_flags: int,
@@ -2007,6 +2732,9 @@ def _display_triage_summary(
         f"{total_llm_flagged} LLM-flagged"
         f"{f', {total_llm_errors} LLM errors' if total_llm_errors else ''}"
         f"{f', {len(pending_approval)} awaiting workflow approval' if 
pending_approval else ''}"
+        f"{f', {len(workflows_in_progress)} workflows in progress' if 
workflows_in_progress else ''}"
+        f"{f', {len(skipped_drafts)} drafts with issues skipped' if 
skipped_drafts else ''}"
+        f"{f', {len(already_triaged)} already triaged' if already_triaged else 
''}"
         f").[/]\n"
     )
 
@@ -2020,18 +2748,23 @@ def _display_triage_summary(
         summary_table.add_row("Bots skipped", str(total_skipped_bot))
         summary_table.add_row("Ready-for-review skipped", 
str(total_skipped_accepted))
     summary_table.add_row("PRs skipped (filtered)", str(total_skipped))
+    summary_table.add_row("Already triaged (skipped)", 
str(len(already_triaged)))
     summary_table.add_row("PRs assessed", str(len(candidate_prs)))
     summary_table.add_row("Flagged by CI/conflicts/comments", 
str(total_deterministic_flags))
     summary_table.add_row("Flagged by LLM", str(total_llm_flagged))
     summary_table.add_row("LLM errors (skipped)", str(total_llm_errors))
     summary_table.add_row("Total flagged", str(total_flagged))
     summary_table.add_row("PRs passing all checks", str(len(passing_prs)))
+    summary_table.add_row("Drafts with issues (skipped)", 
str(len(skipped_drafts)))
     summary_table.add_row("PRs converted to draft", str(stats.total_converted))
     summary_table.add_row("PRs commented (not drafted)", 
str(stats.total_commented))
     summary_table.add_row("PRs closed", str(stats.total_closed))
+    summary_table.add_row("PRs with checks rerun", str(stats.total_rerun))
+    summary_table.add_row("Review follow-up nudges", 
str(stats.total_review_nudges))
     summary_table.add_row("PRs marked ready for review", 
str(stats.total_ready))
     summary_table.add_row("PRs skipped (no action)", 
str(stats.total_skipped_action))
     summary_table.add_row("Awaiting workflow approval", 
str(len(pending_approval)))
+    summary_table.add_row("Workflows in progress (skipped)", 
str(len(workflows_in_progress)))
     summary_table.add_row("PRs with workflows approved", 
str(stats.total_workflows_approved))
     get_console().print(summary_table)
 
@@ -2051,6 +2784,22 @@ def _fetch_pr_diff(token: str, github_repository: str, 
pr_number: int) -> str |
     return response.text
 
 
+def _detect_sensitive_file_changes(diff_text: str) -> list[str]:
+    """Parse a unified diff and return paths under .github/ or scripts/ that 
were changed."""
+    import re
+
+    sensitive_paths: list[str] = []
+    seen: set[str] = set()
+    for match in re.finditer(r"^diff --git a/(\S+) b/(\S+)", diff_text, 
re.MULTILINE):
+        path = match.group(2)
+        if path in seen:
+            continue
+        seen.add(path)
+        if path.startswith((".github/", "scripts/")):
+            sensitive_paths.append(path)
+    return sensitive_paths
+
+
 def _fetch_author_open_prs(token: str, github_repository: str, author_login: 
str) -> list[dict]:
     """Fetch all open PRs by a given author. Returns list of dicts with 
number, url, title, node_id."""
     search_query = f"repo:{github_repository} type:pr is:open 
author:{author_login}"
@@ -2107,14 +2856,19 @@ def _close_suspicious_prs(
     return closed, commented
 
 
-def _find_pending_workflow_runs(token: str, github_repository: str, head_sha: 
str) -> list[dict]:
-    """Find workflow runs awaiting approval for a given commit SHA."""
+def _find_workflow_runs_by_status(
+    token: str, github_repository: str, head_sha: str, status: str
+) -> list[dict]:
+    """Find workflow runs with a given status for a commit SHA.
+
+    Common statuses: ``action_required``, ``in_progress``, ``queued``.
+    """
     import requests
 
     url = f"https://api.github.com/repos/{github_repository}/actions/runs";
     response = requests.get(
         url,
-        params={"head_sha": head_sha, "status": "action_required", "per_page": 
"50"},
+        params={"head_sha": head_sha, "status": status, "per_page": "50"},
         headers={"Authorization": f"Bearer {token}", "Accept": 
"application/vnd.github+json"},
         timeout=30,
     )
@@ -2123,6 +2877,19 @@ def _find_pending_workflow_runs(token: str, 
github_repository: str, head_sha: st
     return response.json().get("workflow_runs", [])
 
 
+def _find_pending_workflow_runs(token: str, github_repository: str, head_sha: 
str) -> list[dict]:
+    """Find workflow runs awaiting approval for a given commit SHA."""
+    return _find_workflow_runs_by_status(token, github_repository, head_sha, 
"action_required")
+
+
+def _has_in_progress_workflows(token: str, github_repository: str, head_sha: 
str) -> bool:
+    """Check whether a PR has any workflow runs currently in progress or 
queued."""
+    for status in ("in_progress", "queued"):
+        if _find_workflow_runs_by_status(token, github_repository, head_sha, 
status):
+            return True
+    return False
+
+
 def _approve_workflow_runs(token: str, github_repository: str, pending_runs: 
list[dict]) -> int:
     """Approve pending workflow runs. Returns number successfully approved."""
     import requests
@@ -2145,6 +2912,107 @@ def _approve_workflow_runs(token: str, 
github_repository: str, pending_runs: lis
     return approved
 
 
+def _cancel_workflow_run(token: str, github_repository: str, run: dict) -> 
bool:
+    """Cancel a single workflow run. Returns True if successful."""
+    import requests
+
+    run_id = run["id"]
+    url = 
f"https://api.github.com/repos/{github_repository}/actions/runs/{run_id}/cancel";
+    response = requests.post(
+        url,
+        headers={"Authorization": f"Bearer {token}", "Accept": 
"application/vnd.github+json"},
+        timeout=30,
+    )
+    return response.status_code in (202, 204)
+
+
+def _rerun_workflow_run(token: str, github_repository: str, run: dict) -> bool:
+    """Rerun a complete workflow run (all jobs). Returns True if successful."""
+    import requests
+
+    run_id = run["id"]
+    url = 
f"https://api.github.com/repos/{github_repository}/actions/runs/{run_id}/rerun";
+    response = requests.post(
+        url,
+        headers={"Authorization": f"Bearer {token}", "Accept": 
"application/vnd.github+json"},
+        timeout=30,
+    )
+    return response.status_code in (201, 204)
+
+
+def _rerun_failed_workflow_runs(
+    token: str, github_repository: str, head_sha: str, failed_check_names: 
list[str]
+) -> int:
+    """Rerun failed workflow runs for a commit. Returns number of runs 
rerun."""
+    import requests
+
+    # Find completed (failed) workflow runs for this SHA
+    runs = _find_workflow_runs_by_status(token, github_repository, head_sha, 
"completed")
+    if not runs:
+        return 0
+
+    # Filter to runs whose names match failed checks
+    failed_set = set(failed_check_names)
+    failed_runs = [r for r in runs if r.get("name") in failed_set or 
r.get("conclusion") == "failure"]
+
+    rerun_count = 0
+    for run in failed_runs:
+        run_id = run["id"]
+        url = 
f"https://api.github.com/repos/{github_repository}/actions/runs/{run_id}/rerun-failed-jobs";
+        response = requests.post(
+            url,
+            headers={"Authorization": f"Bearer {token}", "Accept": 
"application/vnd.github+json"},
+            timeout=30,
+        )
+        if response.status_code in (201, 204):
+            get_console().print(f"  [success]Rerun triggered for: 
{run.get('name', run_id)}[/]")
+            rerun_count += 1
+        else:
+            get_console().print(
+                f"  [warning]Failed to rerun {run.get('name', run_id)}: 
{response.status_code}[/]"
+            )
+    return rerun_count
+
+
+def _cancel_and_rerun_in_progress_workflows(token: str, github_repository: 
str, head_sha: str) -> int:
+    """Cancel in-progress/queued workflow runs and rerun them. Returns number 
rerun."""
+    import time as time_mod
+
+    in_progress = []
+    for status in ("in_progress", "queued"):
+        in_progress.extend(_find_workflow_runs_by_status(token, 
github_repository, head_sha, status))
+    if not in_progress:
+        return 0
+
+    # Cancel all in-progress runs
+    cancelled = 0
+    for run in in_progress:
+        name = run.get("name", run["id"])
+        if _cancel_workflow_run(token, github_repository, run):
+            get_console().print(f"  [info]Cancelled workflow run: {name}[/]")
+            cancelled += 1
+        else:
+            get_console().print(f"  [warning]Failed to cancel: {name}[/]")
+
+    if not cancelled:
+        return 0
+
+    # Brief pause to let GitHub process the cancellations
+    get_console().print("  [dim]Waiting for cancellations to complete...[/]")
+    time_mod.sleep(3)
+
+    # Rerun the cancelled runs
+    rerun_count = 0
+    for run in in_progress:
+        name = run.get("name", run["id"])
+        if _rerun_workflow_run(token, github_repository, run):
+            get_console().print(f"  [success]Rerun triggered for: {name}[/]")
+            rerun_count += 1
+        else:
+            get_console().print(f"  [warning]Failed to rerun: {name}[/]")
+    return rerun_count
+
+
 @pr_group.command(
     name="auto-triage",
     help="Find open PRs from non-collaborators that don't meet quality 
criteria and convert to draft.",
@@ -2206,6 +3074,12 @@ def _approve_workflow_runs(token: str, 
github_repository: str, pending_runs: lis
     default=False,
     help="Include PRs from collaborators/members/owners (normally skipped).",
 )
[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,
@@ -2295,6 +3169,7 @@ def auto_triage(
     updated_after: str | None,
     updated_before: str | None,
     include_collaborators: bool,
+    include_drafts: bool,
     pending_approval_only: bool,
     checks_state: str,
     min_commits_behind: int,
@@ -2334,10 +3209,11 @@ def auto_triage(
 
     dry_run = get_dry_run()
 
-    # Resolve --review-requested: resolve to the authenticated user
+    # Resolve the authenticated user login (used for --review-requested and 
triage comment detection)
+    viewer_login = _resolve_viewer_login(token)
     review_requested_user: str | None = None
     if review_requested:
-        review_requested_user = _resolve_viewer_login(token)
+        review_requested_user = viewer_login
         get_console().print(f"[info]Filtering PRs with review requested for: 
{review_requested_user}[/]")
 
     # Phase 1: Fetch PRs via GraphQL
@@ -2352,12 +3228,14 @@ def auto_triage(
 
     # Phase 1: Lightweight fetch of PRs via GraphQL (no check contexts — fast)
     t_phase1_start = time.monotonic()
+    has_next_page = False
+    next_cursor: str | None = None
     if pr_number:
         get_console().print(f"[info]Fetching PR #{pr_number} via 
GraphQL...[/]")
         all_prs = [_fetch_single_pr_graphql(token, github_repository, 
pr_number)]
     else:
         get_console().print("[info]Fetching PRs via GraphQL...[/]")
-        all_prs = _fetch_prs_graphql(
+        all_prs, has_next_page, next_cursor = _fetch_prs_graphql(
             token,
             github_repository,
             labels=exact_labels,
@@ -2390,19 +3268,54 @@ def auto_triage(
     for pr in all_prs:
         pr.commits_behind = behind_map.get(pr.number, 0)
 
+    # Resolve UNKNOWN mergeable status before displaying the overview table
+    unknown_count = sum(1 for pr in all_prs if pr.mergeable == "UNKNOWN")
+    if unknown_count:
+        get_console().print(
+            f"[info]Resolving merge conflict status for {unknown_count} "
+            f"{'PRs' if unknown_count != 1 else 'PR'} with unknown 
status...[/]"
+        )
+        resolved = _resolve_unknown_mergeable(token, github_repository, 
all_prs)
+        remaining = unknown_count - resolved
+        if remaining:
+            get_console().print(
+                f"  [dim]{resolved} resolved, {remaining} still unknown "
+                f"(GitHub hasn't computed mergeability yet).[/]"
+            )
+        else:
+            get_console().print(f"  [dim]All {resolved} resolved.[/]")
+
     # Display overview and filter candidates
     _display_pr_overview_table(all_prs)
 
-    candidate_prs, total_skipped_collaborator, total_skipped_bot, 
total_skipped_accepted = (
+    candidate_prs, accepted_prs, total_skipped_collaborator, 
total_skipped_bot, total_skipped_accepted = (
         _filter_candidate_prs(
             all_prs,
             include_collaborators=include_collaborators,
+            include_drafts=include_drafts,
             checks_state=checks_state,
             min_commits_behind=min_commits_behind,
             max_num=max_num,
         )
     )
 
+    # Exclude PRs that already have a triage comment posted after the last 
commit
+    get_console().print(
+        "[info]Checking for PRs already triaged (no new commits since last 
triage comment)...[/]"
+    )
+    already_triaged_nums = _find_already_triaged_prs(token, github_repository, 
candidate_prs, viewer_login)
+    already_triaged: list[PRData] = []
+    if already_triaged_nums:
+        already_triaged = [pr for pr in candidate_prs if pr.number in 
already_triaged_nums]
+        candidate_prs = [pr for pr in candidate_prs if pr.number not in 
already_triaged_nums]
+        get_console().print(
+            f"[info]Skipped {len(already_triaged)} already-triaged "
+            f"{'PRs' if len(already_triaged) != 1 else 'PR'} "
+            f"(triage comment posted, no new commits since).[/]"
+        )
+    else:
+        get_console().print("  [dim]None found.[/]")
+
     t_phase1_end = time.monotonic()
 
     # Enrich candidate PRs with check details, mergeable status, and review 
comments
@@ -2415,45 +3328,104 @@ def auto_triage(
     llm_candidates: list[PRData] = []
     passing_prs: list[PRData] = []
     pending_approval: list[PRData] = []
+    workflows_in_progress: list[PRData] = []
+    skipped_drafts: list[PRData] = []  # Draft PRs skipped because they have 
other issues
     total_deterministic_flags = 0
     deterministic_timings: dict[int, float] = {}  # PR number -> deterministic 
triage duration
 
+    def _categorize_pr(pr: PRData) -> None:
+        """Route a PR to pending_approval, workflows_in_progress, or 
llm_candidates."""
+        if pr.checks_state == "NOT_RUN":
+            # No workflow runs yet — needs workflow approval.
+            # But first check if workflows are already running — skip those.
+            if pr.head_sha and _has_in_progress_workflows(token, 
github_repository, pr.head_sha):
+                workflows_in_progress.append(pr)
+            else:
+                pending_approval.append(pr)
+        elif (
+            pr.is_draft and pr.head_sha and _has_in_progress_workflows(token, 
github_repository, pr.head_sha)
+        ):
+            # Draft PRs with workflows still running — author is still 
iterating
+            workflows_in_progress.append(pr)
+        else:
+            llm_candidates.append(pr)
+
     if run_ci:
         for pr in candidate_prs:
             t_det_start = time.monotonic()
             ci_assessment = assess_pr_checks(pr.number, pr.checks_state, 
pr.failed_checks)
+
+            # Race condition: checks_state is FAILURE but workflows are 
currently running.
+            # The rollup state may be stale from previous runs while new runs 
are in progress.
+            # In that case, skip the CI failure and treat as workflows in 
progress.
+            if (
+                ci_assessment
+                and pr.head_sha
+                and _has_in_progress_workflows(token, github_repository, 
pr.head_sha)
+            ):
+                workflows_in_progress.append(pr)
+                deterministic_timings[pr.number] = time.monotonic() - 
t_det_start
+                continue
+
             conflict_assessment = assess_pr_conflicts(pr.number, pr.mergeable, 
pr.base_ref, pr.commits_behind)
             comments_assessment = assess_pr_unresolved_comments(pr.number, 
pr.unresolved_review_comments)
 
             if ci_assessment or conflict_assessment or comments_assessment:
-                total_deterministic_flags += 1
-                violations = []
-                summaries = []
-                if conflict_assessment:
-                    violations.extend(conflict_assessment.violations)
-                    summaries.append(conflict_assessment.summary)
-                if ci_assessment:
-                    violations.extend(ci_assessment.violations)
-                    summaries.append(ci_assessment.summary)
-                if comments_assessment:
-                    violations.extend(comments_assessment.violations)
-                    summaries.append(comments_assessment.summary)
-                assessments[pr.number] = PRAssessment(
-                    should_flag=True,
-                    violations=violations,
-                    summary=" ".join(summaries),
-                )
-            elif pr.checks_state == "NOT_RUN":
-                pending_approval.append(pr)
+                if pr.is_draft:
+                    # Draft PRs with issues are skipped — the author is still 
working on them
+                    skipped_drafts.append(pr)
+                else:
+                    total_deterministic_flags += 1
+                    violations = []
+                    summaries = []
+                    if conflict_assessment:
+                        violations.extend(conflict_assessment.violations)
+                        summaries.append(conflict_assessment.summary)
+                    if ci_assessment:
+                        violations.extend(ci_assessment.violations)
+                        summaries.append(ci_assessment.summary)
+                    if comments_assessment:
+                        violations.extend(comments_assessment.violations)
+                        summaries.append(comments_assessment.summary)
+                    assessments[pr.number] = PRAssessment(
+                        should_flag=True,
+                        violations=violations,
+                        summary=" ".join(summaries),
+                    )
             else:
-                llm_candidates.append(pr)
+                _categorize_pr(pr)
             deterministic_timings[pr.number] = time.monotonic() - t_det_start
     else:
         for pr in candidate_prs:
-            if pr.checks_state == "NOT_RUN":
-                pending_approval.append(pr)
-            else:
-                llm_candidates.append(pr)
+            _categorize_pr(pr)
+
+    if skipped_drafts:
+        get_console().print(
+            f"[info]Skipped {len(skipped_drafts)} draft "
+            f"{'PRs' if len(skipped_drafts) != 1 else 'PR'} "
+            f"with existing issues (CI failures, conflicts, or unresolved 
comments).[/]"
+        )
+    if workflows_in_progress:
+        get_console().print(
+            f"[info]Excluded {len(workflows_in_progress)} "
+            f"{'PRs' if len(workflows_in_progress) != 1 else 'PR'} "
+            f"with workflows already in progress.[/]"
+        )
+
+    # Filter out pending_approval PRs that already have a comment from the 
viewer
+    # (triage or rebase comment) with no new commits since — no point 
re-approving
+    if pending_approval and viewer_login:
+        already_commented_nums = _find_already_triaged_prs(
+            token, github_repository, pending_approval, viewer_login, 
require_marker=False
+        )
+        if already_commented_nums:
+            already_triaged.extend(pr for pr in pending_approval if pr.number 
in already_commented_nums)
+            pending_approval = [pr for pr in pending_approval if pr.number not 
in already_commented_nums]
+            get_console().print(
+                f"[info]Skipped {len(already_commented_nums)} 
workflow-approval "
+                f"{'PRs' if len(already_commented_nums) != 1 else 'PR'} "
+                f"already commented on (no new commits since).[/]"
+            )
 
     # If --pending-approval-only, discard all assessment results and only keep 
pending_approval
     if pending_approval_only:
@@ -2485,8 +3457,14 @@ def auto_triage(
             passing_prs.extend(llm_candidates)
     elif llm_candidates:
         skipped_detail = f"{total_deterministic_flags} CI/conflicts/comments"
+        if skipped_drafts:
+            skipped_detail += f", {len(skipped_drafts)} drafts with issues"
+        if already_triaged:
+            skipped_detail += f", {len(already_triaged)} already triaged"
         if pending_approval:
             skipped_detail += f", {len(pending_approval)} awaiting workflow 
approval"
+        if workflows_in_progress:
+            skipped_detail += f", {len(workflows_in_progress)} workflows in 
progress"
         get_console().print(
             f"\n[info]Starting LLM assessment for {len(llm_candidates)} "
             f"{'PRs' if len(llm_candidates) != 1 else 'PR'} in background "
@@ -2527,26 +3505,221 @@ def auto_triage(
         llm_passing=llm_passing,
     )
 
-    # Phase 4b: Present NOT_RUN PRs for workflow approval (LLM runs in 
background)
-    _review_workflow_approval_prs(ctx, pending_approval)
+    try:
+        # Phase 4b: Present NOT_RUN PRs for workflow approval (LLM runs in 
background)
+        _review_workflow_approval_prs(ctx, pending_approval)
+
+        # Phase 5a: Present deterministically flagged PRs
+        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))
+        _review_deterministic_flagged_prs(ctx, det_flagged_prs)
+
+        # Phase 5b: Present LLM-flagged PRs as they become ready (streaming)
+        _review_llm_flagged_prs(ctx, llm_candidates)
+
+        # 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)
+
+        # Phase 5d: Check accepted PRs for stale CHANGES_REQUESTED reviews
+        _review_stale_review_requests(ctx, accepted_prs)
+    except KeyboardInterrupt:
+        get_console().print("\n[warning]Interrupted — shutting down.[/]")
+        stats.quit_early = True
+    finally:
+        # Shut down LLM executor if it was started
+        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
+    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]
+        get_console().print(f"\n[info]Batch complete. Fetching next batch 
(page {batch_num})...[/]\n")
+        all_prs, has_next_page, next_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_user,
+            after_cursor=next_cursor,
+        )
+        if not all_prs:
+            get_console().print("[info]No more PRs to process.[/]")
+            break
+
+        # Apply wildcard label filters client-side
+        if wildcard_labels:
+            all_prs = [
+                pr for pr in all_prs if any(fnmatch(lbl, pat) for pat in 
wildcard_labels for lbl in pr.labels)
+            ]
+        if wildcard_exclude_labels:
+            all_prs = [
+                pr
+                for pr in all_prs
+                if not any(fnmatch(lbl, pat) for pat in 
wildcard_exclude_labels for lbl in pr.labels)
+            ]
+
+        # Enrich: commits behind, mergeable status
+        behind_map = _fetch_commits_behind_batch(token, github_repository, 
all_prs)
+        for pr in all_prs:
+            pr.commits_behind = behind_map.get(pr.number, 0)
+        unknown_count = sum(1 for pr in all_prs if pr.mergeable == "UNKNOWN")
+        if unknown_count:
+            _resolve_unknown_mergeable(token, github_repository, all_prs)
+
+        _display_pr_overview_table(all_prs)
+
+        (
+            candidate_prs,
+            batch_accepted,
+            _,
+            _,
+            _,
+        ) = _filter_candidate_prs(
+            all_prs,
+            include_collaborators=include_collaborators,
+            include_drafts=include_drafts,
+            checks_state=checks_state,
+            min_commits_behind=min_commits_behind,
+            max_num=max_num,
+        )
+        accepted_prs.extend(batch_accepted)
 
-    # Phase 5a: Present deterministically flagged PRs
-    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))
-    _review_deterministic_flagged_prs(ctx, det_flagged_prs)
+        if not candidate_prs:
+            get_console().print("[info]No candidates in this batch.[/]")
+            continue
+
+        # Check already-triaged
+        batch_triaged_nums = _find_already_triaged_prs(token, 
github_repository, candidate_prs, viewer_login)
+        if batch_triaged_nums:
+            candidate_prs = [pr for pr in candidate_prs if pr.number not in 
batch_triaged_nums]
 
-    # Phase 5b: Present LLM-flagged PRs as they become ready (streaming)
-    _review_llm_flagged_prs(ctx, llm_candidates)
+        if not candidate_prs:
+            get_console().print("[info]All PRs in this batch already 
triaged.[/]")
+            continue
 
-    # Add LLM passing PRs to the passing list
-    passing_prs.extend(llm_passing)
+        # Enrich and assess
+        _enrich_candidate_details(token, github_repository, candidate_prs, 
run_ci=run_ci)
+
+        batch_assessments: dict[int, PRAssessment] = {}
+        batch_llm_candidates: list[PRData] = []
+        batch_passing: list[PRData] = []
+        batch_pending: list[PRData] = []
+
+        if run_ci:
+            for pr in candidate_prs:
+                ci_assessment = assess_pr_checks(pr.number, pr.checks_state, 
pr.failed_checks)
+                if (
+                    ci_assessment
+                    and pr.head_sha
+                    and _has_in_progress_workflows(token, github_repository, 
pr.head_sha)
+                ):
+                    continue
+                conflict_assessment = assess_pr_conflicts(
+                    pr.number, pr.mergeable, pr.base_ref, pr.commits_behind
+                )
+                comments_assessment = assess_pr_unresolved_comments(pr.number, 
pr.unresolved_review_comments)
+                if ci_assessment or conflict_assessment or comments_assessment:
+                    if pr.is_draft:
+                        continue
+                    violations = []
+                    summaries = []
+                    if conflict_assessment:
+                        violations.extend(conflict_assessment.violations)
+                        summaries.append(conflict_assessment.summary)
+                    if ci_assessment:
+                        violations.extend(ci_assessment.violations)
+                        summaries.append(ci_assessment.summary)
+                    if comments_assessment:
+                        violations.extend(comments_assessment.violations)
+                        summaries.append(comments_assessment.summary)
+                    batch_assessments[pr.number] = PRAssessment(
+                        should_flag=True,
+                        violations=violations,
+                        summary=" ".join(summaries),
+                    )
+                elif pr.checks_state == "NOT_RUN":
+                    batch_pending.append(pr)
+                else:
+                    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)
+
+        # LLM assessment for this batch
+        batch_llm_future_to_pr: dict = {}
+        batch_llm_assessments: dict[int, PRAssessment] = {}
+        batch_llm_completed: list = []
+        batch_llm_errors: list[int] = []
+        batch_llm_passing: list[PRData] = []
+        batch_executor = None
+
+        if not run_llm:
+            batch_passing.extend(batch_llm_candidates)
+        elif batch_llm_candidates:
+            batch_executor = ThreadPoolExecutor(max_workers=llm_concurrency)
+            batch_llm_future_to_pr = {
+                batch_executor.submit(
+                    assess_pr,
+                    pr_number=pr.number,
+                    pr_title=pr.title,
+                    pr_body=pr.body,
+                    check_status_summary=pr.check_summary,
+                    llm_model=llm_model,
+                ): pr
+                for pr in batch_llm_candidates
+            }
 
-    # Phase 5c: Present passing PRs for optional ready-for-review marking
-    _review_passing_prs(ctx, passing_prs)
+        batch_author_flagged_count: dict[str, int] = dict(
+            Counter(pr.author_login for pr in candidate_prs if pr.number in 
batch_assessments)
+        )
+        batch_ctx = TriageContext(
+            token=token,
+            github_repository=github_repository,
+            dry_run=dry_run,
+            answer_triage=answer_triage,
+            stats=stats,
+            author_flagged_count=batch_author_flagged_count,
+            llm_future_to_pr=batch_llm_future_to_pr,
+            llm_assessments=batch_llm_assessments,
+            llm_completed=batch_llm_completed,
+            llm_errors=batch_llm_errors,
+            llm_passing=batch_llm_passing,
+        )
 
-    # Shut down LLM executor if it was started
-    if llm_executor is not None:
-        llm_executor.shutdown(wait=False, cancel_futures=True)
+        try:
+            _review_workflow_approval_prs(batch_ctx, batch_pending)
+
+            det_flagged = [
+                (pr, batch_assessments[pr.number]) for pr in candidate_prs if 
pr.number in batch_assessments
+            ]
+            det_flagged.sort(key=lambda pair: (pair[0].author_login.lower(), 
pair[0].number))
+            _review_deterministic_flagged_prs(batch_ctx, det_flagged)
+
+            _review_llm_flagged_prs(batch_ctx, batch_llm_candidates)
+            batch_passing.extend(batch_llm_passing)
+
+            _review_passing_prs(batch_ctx, batch_passing)
+            _review_stale_review_requests(batch_ctx, batch_accepted)
+        except KeyboardInterrupt:
+            get_console().print("\n[warning]Interrupted — shutting down.[/]")
+            stats.quit_early = True
+        finally:
+            if batch_executor is not None:
+                batch_executor.shutdown(wait=False, cancel_futures=True)
 
     # Display summary
     _display_triage_summary(
@@ -2554,6 +3727,9 @@ def auto_triage(
         candidate_prs,
         passing_prs,
         pending_approval,
+        workflows_in_progress,
+        skipped_drafts,
+        already_triaged,
         stats,
         total_deterministic_flags=total_deterministic_flags,
         total_llm_flagged=len(llm_assessments),
@@ -2636,11 +3812,14 @@ def auto_triage(
         "",
     )
     timing_table.add_row("", "", "", "", "", "")
+    total_elapsed = t_total_end - t_total_start
+    prs_with_action = len(pr_actions)
+    avg_per_actioned = _fmt_duration(total_elapsed / prs_with_action) if 
prs_with_action else "[dim]—[/]"
     timing_table.add_row(
         "[bold]Total[/]",
-        f"[bold]{_fmt_duration(t_total_end - t_total_start)}[/]",
-        "",
-        "",
+        f"[bold]{_fmt_duration(total_elapsed)}[/]",
+        str(prs_with_action) if prs_with_action else "",
+        f"[bold]{avg_per_actioned}[/]" if prs_with_action else "",
         "",
         "",
     )
@@ -2681,7 +3860,9 @@ def auto_triage(
             det_time = deterministic_timings.get(pr_num, 0)
             total_time = fetch_per_pr + det_time
 
-            if pr_num in assessments or pr_num in llm_assessments:
+            if any(pr.number == pr_num for pr in skipped_drafts):
+                result = "[dim]draft-skipped[/]"
+            elif pr_num in assessments or pr_num in llm_assessments:
                 result = "[red]flagged[/]"
             elif any(pr.number == pr_num for pr in passing_prs) or any(
                 pr.number == pr_num for pr in llm_passing
@@ -2689,6 +3870,8 @@ def auto_triage(
                 result = "[success]passed[/]"
             elif any(pr.number == pr_num for pr in pending_approval):
                 result = "[dim]pending[/]"
+            elif any(pr.number == pr_num for pr in workflows_in_progress):
+                result = "[cyan]running[/]"
             else:
                 result = "[yellow]error[/]"
 
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 cd683a7dda4..f125295b15f 100644
--- a/dev/breeze/src/airflow_breeze/commands/pr_commands_config.py
+++ b/dev/breeze/src/airflow_breeze/commands/pr_commands_config.py
@@ -42,6 +42,7 @@ PR_PARAMETERS: dict[str, list[dict[str, str | list[str]]]] = {
                 "--updated-after",
                 "--updated-before",
                 "--include-collaborators",
+                "--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 5ca0de4b86f..c2a3a186448 100644
--- a/dev/breeze/src/airflow_breeze/utils/confirm.py
+++ b/dev/breeze/src/airflow_breeze/utils/confirm.py
@@ -26,6 +26,28 @@ from airflow_breeze.utils.shared_options import 
get_forced_answer
 STANDARD_TIMEOUT = 10
 
 
+def _has_tty() -> bool:
+    """Check if a TTY is available for single-keypress input."""
+    try:
+        f = open("/dev/tty")
+        f.close()
+        return True
+    except OSError:
+        return False
+
+
+def _read_char() -> str:
+    """Read a single character — uses click.getchar() if TTY is available, 
else input()."""
+    if _has_tty():
+        import click
+
+        return click.getchar()
+    # No TTY (CI) — fall back to line-buffered input
+    from inputimeout import inputimeout
+
+    return inputimeout(prompt="", timeout=None)
+
+
 class Answer(Enum):
     YES = "y"
     NO = "n"
@@ -41,61 +63,63 @@ def user_confirm(
 ) -> Answer:
     """Ask the user for confirmation.
 
+    Uses single-keypress input (no Enter required) when a TTY is available.
+    Falls back to line-buffered input in CI environments.
+
     :param message: message to display to the user (should end with the 
question mark)
     :param timeout: time given user to answer
     :param default_answer: default value returned on timeout. If no default - 
is set, the timeout is ignored.
     :param quit_allowed: whether quit answer is allowed
     :param forced_answer: explicit override for forced answer (bypasses global 
--answer)
     """
-    from inputimeout import TimeoutOccurred, inputimeout
-
     allowed_answers = "y/n/q" if quit_allowed else "y/n"
-    while True:
-        try:
-            force = forced_answer or get_forced_answer() or 
os.environ.get("ANSWER")
-            if force:
-                user_status = force
-                print(f"Forced answer for '{message}': {force}")
-            else:
-                if default_answer:
-                    # Capitalise default answer
-                    allowed_answers = allowed_answers.replace(
-                        default_answer.value, default_answer.value.upper()
-                    )
-                    timeout_answer = default_answer.value
-                else:
-                    timeout = None
-                    timeout_answer = ""
-                message_prompt = f"\n{message} \nPress {allowed_answers}"
-                if default_answer and timeout:
-                    message_prompt += (
-                        f". Auto-select {timeout_answer} in {timeout} seconds "
-                        f"(add `--answer {default_answer.value}` to avoid 
delay next time)"
-                    )
-                message_prompt += ": "
-                user_status = inputimeout(
-                    prompt=message_prompt,
-                    timeout=timeout,
-                )
-                if user_status == "":
-                    if default_answer:
-                        return default_answer
-                    continue
-            if user_status.upper() in ["Y", "YES"]:
-                return Answer.YES
-            if user_status.upper() in ["N", "NO"]:
-                return Answer.NO
-            if user_status.upper() in ["Q", "QUIT"] and quit_allowed:
-                return Answer.QUIT
-            print(f"Wrong answer given {user_status}. Should be one of 
{allowed_answers}. Try again.")
-        except TimeoutOccurred:
-            if default_answer:
-                return default_answer
-            # timeout should only occur when default_answer is set so this 
should never happened
-        except KeyboardInterrupt:
-            if quit_allowed:
-                return Answer.QUIT
-            sys.exit(1)
+    force = forced_answer or get_forced_answer() or os.environ.get("ANSWER")
+    if force:
+        print(f"Forced answer for '{message}': {force}")
+        if force.upper() in ("Y", "YES"):
+            return Answer.YES
+        if force.upper() in ("N", "NO"):
+            return Answer.NO
+        if force.upper() in ("Q", "QUIT") and quit_allowed:
+            return Answer.QUIT
+        return default_answer or Answer.NO
+
+    if default_answer:
+        allowed_answers = allowed_answers.replace(default_answer.value, 
default_answer.value.upper())
+
+    prompt = f"\n{message} \nPress {allowed_answers}: "
+    get_console().print(prompt, end="")
+
+    try:
+        ch = _read_char()
+    except (KeyboardInterrupt, EOFError):
+        get_console().print()
+        if quit_allowed:
+            return Answer.QUIT
+        sys.exit(1)
+
+    # Ignore multi-byte escape sequences (arrow keys, etc.)
+    if len(ch) > 1:
+        get_console().print()
+        if default_answer:
+            return default_answer
+        return Answer.NO
+
+    get_console().print(ch)
+
+    if ch.upper() == "Y":
+        return Answer.YES
+    if ch.upper() == "N":
+        return Answer.NO
+    if ch.upper() == "Q" and quit_allowed:
+        return Answer.QUIT
+    # Enter/Return selects the default
+    if ch in ("\r", "\n", "") and default_answer:
+        return default_answer
+    # Any other key — treat as default if available
+    if default_answer:
+        return default_answer
+    return Answer.NO
 
 
 def confirm_action(
@@ -117,9 +141,11 @@ def confirm_action(
 
 class TriageAction(Enum):
     DRAFT = "d"
-    COMMENT = "a"
-    CLOSE = "c"
-    READY = "r"
+    COMMENT = "c"
+    CLOSE = "x"
+    RERUN = "r"
+    OPEN = "o"
+    READY = "m"
     SKIP = "s"
     QUIT = "q"
 
@@ -129,71 +155,95 @@ def prompt_triage_action(
     default: TriageAction = TriageAction.DRAFT,
     timeout: float | None = None,
     forced_answer: str | None = None,
+    exclude: set[TriageAction] | None = None,
+    pr_url: str | None = None,
 ) -> TriageAction:
     """Prompt the user to choose a triage action for a flagged PR.
 
+    Uses single-keypress input (no Enter required) when a TTY is available.
+    Falls back to line-buffered input in CI environments.
+
     :param message: message to display (should describe the PR)
     :param default: default action returned on Enter or timeout
     :param timeout: seconds before auto-selecting default (None = no timeout)
     :param forced_answer: explicit override for forced answer (bypasses global 
--answer)
+    :param pr_url: URL of the PR (used by OPEN action to open in browser)
     """
-    from inputimeout import TimeoutOccurred, inputimeout
+    import webbrowser
 
     _LABELS = {
         TriageAction.DRAFT: "draft",
-        TriageAction.COMMENT: "add comment",
+        TriageAction.COMMENT: "comment",
         TriageAction.CLOSE: "close",
-        TriageAction.READY: "ready",
+        TriageAction.RERUN: "rerun checks",
+        TriageAction.OPEN: "open in browser",
+        TriageAction.READY: "mark as ready",
         TriageAction.SKIP: "skip",
         TriageAction.QUIT: "quit",
     }
+
+    force = forced_answer or get_forced_answer() or os.environ.get("ANSWER")
+    if force:
+        print(f"Forced answer for '{message}': {force}")
+        upper = force.upper()
+        if upper in ("Y", "YES"):
+            return default
+        if upper in ("N", "NO"):
+            return TriageAction.SKIP
+        if upper == "Q":
+            return TriageAction.QUIT
+        for action in TriageAction:
+            if upper == action.value.upper():
+                return action
+        return default
+
+    excluded = exclude or set()
+    available_actions = [a for a in TriageAction if a not in excluded]
+    action_by_key = {a.value.upper(): a for a in available_actions}
+
     while True:
+        # Build choice display: uppercase the default letter
+        # Use escaped brackets so Rich doesn't interpret them as markup tags
+        choices = []
+        for action in available_actions:
+            letter = action.value
+            label = _LABELS[action]
+            if action == default:
+                choices.append(f"\\[{letter.upper()}]{label}")
+            else:
+                choices.append(f"\\[{letter}]{label}")
+        choices_str = " / ".join(choices)
+
+        get_console().print(f"\n{message}")
+        get_console().print(choices_str + ": ", end="")
+
         try:
-            force = forced_answer or get_forced_answer() or 
os.environ.get("ANSWER")
-            if force:
-                print(f"Forced answer for '{message}': {force}")
-                upper = force.upper()
-                if upper in ("Y", "YES"):
-                    return default
-                if upper in ("N", "NO"):
-                    return TriageAction.SKIP
-                if upper == "Q":
-                    return TriageAction.QUIT
-                for action in TriageAction:
-                    if upper == action.value.upper():
-                        return action
-                return default
-
-            # Build choice display: uppercase the default letter
-            choices = []
-            for action in TriageAction:
-                letter = action.value
-                label = _LABELS[action]
-                if action == default:
-                    choices.append(f"[{letter.upper()}]{label}")
-                else:
-                    choices.append(f"[{letter}]{label}")
-            choices_str = " / ".join(choices)
-
-            get_console().print(f"\n{message}")
-            prompt_text = choices_str
-            if timeout:
-                prompt_text += (
-                    f"  (auto-select {_LABELS[default]} in {timeout}s"
-                    f" — add `--answer-triage {default.value}` to skip)"
-                )
-            prompt_text += ": "
-
-            user_input = inputimeout(prompt=prompt_text, timeout=timeout)
-            if user_input == "":
-                return default
-
-            upper = user_input.strip().upper()
-            for action in TriageAction:
-                if upper == action.value.upper():
-                    return action
-            print(f"Invalid input '{user_input}'. Please enter one of: 
d/a/c/r/s/q")
-        except TimeoutOccurred:
-            return default
-        except KeyboardInterrupt:
+            ch = _read_char()
+        except (KeyboardInterrupt, EOFError):
+            get_console().print()
             return TriageAction.QUIT
+
+        # Ignore multi-byte escape sequences (arrow keys, etc.)
+        if len(ch) > 1:
+            get_console().print()
+            continue
+
+        get_console().print(ch)
+
+        # Enter/Return or empty string (line-buffered) selects the default
+        if ch in ("\r", "\n", ""):
+            return default
+
+        matched = action_by_key.get(ch.upper())
+        if matched:
+            if matched == TriageAction.OPEN:
+                if pr_url:
+                    webbrowser.open(pr_url)
+                    get_console().print(f"  [info]Opened {pr_url} in 
browser.[/]")
+                else:
+                    get_console().print("  [warning]No PR URL available to 
open.[/]")
+                continue  # re-prompt after opening browser
+            return matched
+
+        valid = "/".join(a.value for a in available_actions)
+        get_console().print(f"  [warning]Invalid key. Press one of: 
{valid}[/]")


Reply via email to