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