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 3996a4a3413 Add timing and per-PR phase breakdown to auto-triage 
command (#63315)
3996a4a3413 is described below

commit 3996a4a34131b34a01276bc85363ac298e41adbb
Author: Jarek Potiuk <[email protected]>
AuthorDate: Wed Mar 11 13:51:20 2026 +0100

    Add timing and per-PR phase breakdown to auto-triage command (#63315)
    
    Add per-phase timing to auto-triage with a summary table showing
    duration for each phase (fetch, check details, review comments,
    assessment, interactive review, total) plus LLM assessment stats
    (fastest/slowest/average).
    
    Display PR title alongside PR number in LLM assessment output.
    
    Add a per-PR phase timing table showing fetch (amortized),
    deterministic triage, and LLM assessment duration for each PR,
    sorted by total time (slowest first).
    
    Co-authored-by: Claude Opus 4.6 <[email protected]>
---
 .../src/airflow_breeze/commands/pr_commands.py     | 220 ++++++++++++++++++++-
 1 file changed, 214 insertions(+), 6 deletions(-)

diff --git a/dev/breeze/src/airflow_breeze/commands/pr_commands.py 
b/dev/breeze/src/airflow_breeze/commands/pr_commands.py
index 82403bba909..13f8ce631d7 100644
--- a/dev/breeze/src/airflow_breeze/commands/pr_commands.py
+++ b/dev/breeze/src/airflow_breeze/commands/pr_commands.py
@@ -17,6 +17,7 @@
 from __future__ import annotations
 
 import sys
+import time
 from concurrent.futures import ThreadPoolExecutor, as_completed
 from dataclasses import dataclass
 
@@ -1078,6 +1079,19 @@ def _compute_default_action(
     return action, f"{reason} — suggesting {action_label}"
 
 
+def _fmt_duration(seconds: float) -> str:
+    """Format a duration in seconds to a human-friendly string like '2m 05s' 
or '3.2s'."""
+    if seconds < 60:
+        return f"{seconds:.1f}s"
+    minutes = int(seconds) // 60
+    secs = int(seconds) % 60
+    if minutes < 60:
+        return f"{minutes}m {secs:02d}s"
+    hours = minutes // 60
+    mins = minutes % 60
+    return f"{hours}h {mins:02d}m {secs:02d}s"
+
+
 def _pr_link(pr: PRData) -> str:
     """Return a Rich-markup clickable link for a PR: 
[link=url]#number[/link]."""
     return f"[link={pr.url}]#{pr.number}[/link]"
@@ -1565,7 +1579,10 @@ def auto_triage(
     exact_exclude_labels = tuple(lbl for lbl in exclude_labels if "*" not in 
lbl and "?" not in lbl)
     wildcard_exclude_labels = [lbl for lbl in exclude_labels if "*" in lbl or 
"?" in lbl]
 
+    t_total_start = time.monotonic()
+
     # Phase 1: Lightweight fetch of PRs via GraphQL (no check contexts — fast)
+    t_phase1_start = time.monotonic()
     if pr_number:
         get_console().print(f"[info]Fetching PR #{pr_number} via 
GraphQL...[/]")
         all_prs = [_fetch_single_pr_graphql(token, github_repository, 
pr_number)]
@@ -1654,6 +1671,8 @@ def auto_triage(
         )
     get_console().print()
 
+    t_phase1_end = time.monotonic()
+
     # Phase 2: Filter out collaborators, bots, and ready-for-review PRs, then 
apply post-fetch filters
     candidate_prs: list[PRData] = []
     total_skipped_collaborator = 0
@@ -1722,6 +1741,7 @@ def auto_triage(
     )
 
     # Phase 2b: Fetch detailed check contexts only for candidate PRs (chunked 
to avoid timeouts)
+    t_phase2b_start = time.monotonic()
     if candidate_prs:
         get_console().print(
             f"[info]Fetching check details for {len(candidate_prs)} "
@@ -1755,6 +1775,8 @@ def auto_triage(
         else:
             get_console().print(f"  [dim]All {resolved} resolved.[/]")
 
+    t_phase2b_end = time.monotonic()
+
     # Phase 2c: Fetch unresolved review comment counts for candidate PRs
     if candidate_prs and run_ci:
         get_console().print(
@@ -1763,6 +1785,8 @@ def auto_triage(
         )
         _fetch_unresolved_comments_batch(token, github_repository, 
candidate_prs)
 
+    t_phase2c_end = time.monotonic()
+
     # Phase 3: Deterministic checks (CI failures + merge conflicts + 
unresolved comments),
     # then LLM for the rest
     # PRs with NOT_RUN checks are separated for workflow approval instead of 
LLM assessment.
@@ -1771,9 +1795,11 @@ def auto_triage(
     passing_prs: list[PRData] = []
     pending_approval: list[PRData] = []
     total_deterministic_flags = 0
+    deterministic_timings: dict[int, float] = {}  # PR number -> deterministic 
triage duration
 
     if run_ci:
         for pr in candidate_prs:
+            t_det_start = time.monotonic()
             ci_assessment = assess_pr_checks(pr.number, pr.checks_state, 
pr.failed_checks)
             conflict_assessment = assess_pr_conflicts(pr.number, pr.mergeable, 
pr.base_ref, pr.commits_behind)
             comments_assessment = assess_pr_unresolved_comments(pr.number, 
pr.unresolved_review_comments)
@@ -1801,6 +1827,7 @@ def auto_triage(
                 pending_approval.append(pr)
             else:
                 llm_candidates.append(pr)
+            deterministic_timings[pr.number] = time.monotonic() - t_det_start
     else:
         for pr in candidate_prs:
             if pr.checks_state == "NOT_RUN":
@@ -1823,6 +1850,7 @@ def auto_triage(
 
     # Phase 4: Run LLM assessments concurrently for PRs without CI failures
     total_llm_errors = 0
+    pr_timings: dict[int, float] = {}  # PR number -> assessment duration in 
seconds
 
     if not run_llm:
         if llm_candidates:
@@ -1840,27 +1868,38 @@ def auto_triage(
             f"{'PRs' if len(llm_candidates) != 1 else 'PR'} (skipped 
{skipped_detail})...[/]\n"
         )
         with ThreadPoolExecutor(max_workers=llm_concurrency) as executor:
-            future_to_pr = {
-                executor.submit(
+            future_to_pr = {}
+            future_start_times: dict[int, float] = {}
+            for pr in llm_candidates:
+                get_console().print(f"  [dim]Assessing PR {_pr_link(pr)}: 
{pr.title[:60]}...[/]")
+                future_start_times[pr.number] = time.monotonic()
+                future = executor.submit(
                     assess_pr,
                     pr_number=pr.number,
                     pr_title=pr.title,
                     pr_body=pr.body,
                     check_status_summary=pr.check_summary,
                     llm_model=llm_model,
-                ): pr
-                for pr in llm_candidates
-            }
+                )
+                future_to_pr[future] = pr
             for future in as_completed(future_to_pr):
                 pr = future_to_pr[future]
+                pr_timings[pr.number] = time.monotonic() - 
future_start_times[pr.number]
                 assessment = future.result()
                 if assessment.error:
                     total_llm_errors += 1
                     continue
                 if not assessment.should_flag:
-                    get_console().print(f"  [success]PR {_pr_link(pr)} passes 
quality check.[/]")
+                    get_console().print(
+                        f"  [success]PR {_pr_link(pr)}: {pr.title[:60]} — 
passes quality check "
+                        f"({_fmt_duration(pr_timings[pr.number])}).[/]"
+                    )
                     passing_prs.append(pr)
                     continue
+                get_console().print(
+                    f"  [red]PR {_pr_link(pr)}: {pr.title[:60]} — flagged by 
LLM "
+                    f"({_fmt_duration(pr_timings[pr.number])}).[/]"
+                )
                 assessments[pr.number] = assessment
 
     total_flagged = len(assessments)
@@ -1877,12 +1916,17 @@ def auto_triage(
         f"flagged ({', '.join(summary_parts)}).[/]\n"
     )
 
+    t_phase4_end = time.monotonic()
+
     # Phase 5: Present flagged PRs interactively, grouped by author
+    t_phase5_start = time.monotonic()
     total_converted = 0
     total_commented = 0
     total_closed = 0
     total_ready = 0
     total_skipped_action = 0
+    pr_actions: dict[int, str] = {}  # PR number -> action taken by user
+
     quit_early = False
 
     # Build sorted list of flagged PRs grouped by author
@@ -1955,6 +1999,7 @@ def auto_triage(
         if action == TriageAction.SKIP:
             get_console().print(f"  [info]Skipping PR {_pr_link(pr)} — no 
action taken.[/]")
             total_skipped_action += 1
+            pr_actions[pr.number] = "skipped"
             continue
 
         if action == TriageAction.READY:
@@ -1966,6 +2011,7 @@ def auto_triage(
                     f"  [success]Label '{_READY_FOR_REVIEW_LABEL}' added to PR 
{_pr_link(pr)}.[/]"
                 )
                 total_ready += 1
+                pr_actions[pr.number] = "ready"
             else:
                 get_console().print(f"  [warning]Failed to add label to PR 
{_pr_link(pr)}.[/]")
             continue
@@ -1975,6 +2021,7 @@ def auto_triage(
             if _post_comment(token, pr.node_id, comment_only):
                 get_console().print(f"  [success]Comment posted on PR 
{_pr_link(pr)}.[/]")
                 total_commented += 1
+                pr_actions[pr.number] = "commented"
             else:
                 get_console().print(f"  [error]Failed to post comment on PR 
{_pr_link(pr)}.[/]")
             continue
@@ -1991,6 +2038,7 @@ def auto_triage(
             if _post_comment(token, pr.node_id, comment):
                 get_console().print(f"  [success]Comment posted on PR 
{_pr_link(pr)}.[/]")
                 total_converted += 1
+                pr_actions[pr.number] = "drafted"
             else:
                 get_console().print(f"  [error]Failed to post comment on PR 
{_pr_link(pr)}.[/]")
             continue
@@ -2014,6 +2062,7 @@ def auto_triage(
             if _post_comment(token, pr.node_id, close_comment):
                 get_console().print(f"  [success]Comment posted on PR 
{_pr_link(pr)}.[/]")
                 total_closed += 1
+                pr_actions[pr.number] = "closed"
             else:
                 get_console().print(f"  [error]Failed to post comment on PR 
{_pr_link(pr)}.[/]")
 
@@ -2053,11 +2102,13 @@ def auto_triage(
                         f"  [success]Label '{_READY_FOR_REVIEW_LABEL}' added 
to PR {_pr_link(pr)}.[/]"
                     )
                     total_ready += 1
+                    pr_actions[pr.number] = "ready"
                 else:
                     get_console().print(f"  [warning]Failed to add label to PR 
{_pr_link(pr)}.[/]")
             else:
                 get_console().print(f"  [info]Skipping PR {_pr_link(pr)} — no 
action taken.[/]")
                 total_skipped_action += 1
+                pr_actions[pr.number] = "skipped"
 
     # Phase 6: Present NOT_RUN PRs for workflow approval
     total_workflows_approved = 0
@@ -2098,6 +2149,7 @@ def auto_triage(
                     break
                 if action == TriageAction.SKIP:
                     get_console().print(f"  [info]Skipping PR {_pr_link(pr)} — 
no action taken.[/]")
+                    pr_actions[pr.number] = "skipped"
                     continue
                 if action == TriageAction.CLOSE:
                     get_console().print(f"  Closing PR {_pr_link(pr)}...")
@@ -2116,6 +2168,7 @@ def auto_triage(
                     if _post_comment(token, pr.node_id, close_comment):
                         get_console().print(f"  [success]Comment posted on PR 
{_pr_link(pr)}.[/]")
                         total_closed += 1
+                        pr_actions[pr.number] = "closed"
                     else:
                         get_console().print(f"  [error]Failed to post comment 
on PR {_pr_link(pr)}.[/]")
                     continue
@@ -2214,6 +2267,7 @@ def auto_triage(
                     f"{'PRs' if len(author_prs) != 1 else 'PR'}, commented on 
{commented}.[/]"
                 )
                 total_closed += closed
+                pr_actions[pr.number] = "suspicious"
                 continue
 
             approved = _approve_workflow_runs(token, github_repository, 
pending_runs)
@@ -2224,10 +2278,12 @@ def auto_triage(
                     f"{_pr_link(pr)}.[/]"
                 )
                 total_workflows_approved += 1
+                pr_actions[pr.number] = "approved"
             else:
                 get_console().print(f"  [error]Failed to approve workflow runs 
for PR {_pr_link(pr)}.[/]")
 
     # Summary
+    t_total_end = time.monotonic()
     get_console().print()
     summary_table = Table(title="Summary")
     summary_table.add_column("Metric", style="bold")
@@ -2253,3 +2309,155 @@ def auto_triage(
     summary_table.add_row("Awaiting workflow approval", 
str(len(pending_approval)))
     summary_table.add_row("PRs with workflows approved", 
str(total_workflows_approved))
     get_console().print(summary_table)
+
+    # Timing summary
+    get_console().print()
+    timing_table = Table(title="Timing Summary")
+    timing_table.add_column("Phase", style="bold")
+    timing_table.add_column("Total", justify="right")
+    timing_table.add_column("PRs", justify="right")
+    timing_table.add_column("Avg/PR", justify="right")
+    timing_table.add_column("Min/PR", justify="right")
+    timing_table.add_column("Max/PR", justify="right")
+
+    num_all = len(all_prs) or 1
+    num_candidates = len(candidate_prs) or 1
+
+    phase1_total = t_phase1_end - t_phase1_start
+    timing_table.add_row(
+        "Fetch PRs + commits behind",
+        _fmt_duration(phase1_total),
+        str(len(all_prs)),
+        _fmt_duration(phase1_total / num_all),
+        "[dim]—[/]",
+        "[dim]—[/]",
+    )
+
+    phase2b_total = t_phase2b_end - t_phase2b_start
+    timing_table.add_row(
+        "Fetch checks + resolve mergeability",
+        _fmt_duration(phase2b_total),
+        str(len(candidate_prs)),
+        _fmt_duration(phase2b_total / num_candidates),
+        "[dim]—[/]",
+        "[dim]—[/]",
+    )
+
+    phase2c_total = t_phase2c_end - t_phase2b_end
+    timing_table.add_row(
+        "Fetch review comments",
+        _fmt_duration(phase2c_total),
+        str(len(candidate_prs)),
+        _fmt_duration(phase2c_total / num_candidates),
+        "[dim]—[/]",
+        "[dim]—[/]",
+    )
+
+    if deterministic_timings:
+        det_values = list(deterministic_timings.values())
+        det_total = sum(det_values)
+        timing_table.add_row(
+            "Deterministic triage",
+            _fmt_duration(det_total),
+            str(len(det_values)),
+            _fmt_duration(det_total / len(det_values)),
+            _fmt_duration(min(det_values)),
+            _fmt_duration(max(det_values)),
+        )
+    else:
+        timing_table.add_row("Deterministic triage", "[dim]—[/]", "0", 
"[dim]—[/]", "[dim]—[/]", "[dim]—[/]")
+
+    if pr_timings:
+        llm_values = list(pr_timings.values())
+        llm_total = sum(llm_values)
+        timing_table.add_row(
+            "LLM assessment",
+            _fmt_duration(t_phase4_end - t_phase2c_end),
+            str(len(llm_values)),
+            _fmt_duration(llm_total / len(llm_values)),
+            _fmt_duration(min(llm_values)),
+            _fmt_duration(max(llm_values)),
+        )
+    else:
+        timing_table.add_row("LLM assessment", "[dim]—[/]", "0", "[dim]—[/]", 
"[dim]—[/]", "[dim]—[/]")
+
+    timing_table.add_row(
+        "Interactive review",
+        _fmt_duration(t_total_end - t_phase5_start),
+        "",
+        "",
+        "",
+        "",
+    )
+    timing_table.add_row("", "", "", "", "", "")
+    timing_table.add_row(
+        "[bold]Total[/]",
+        f"[bold]{_fmt_duration(t_total_end - t_total_start)}[/]",
+        "",
+        "",
+        "",
+        "",
+    )
+    get_console().print(timing_table)
+
+    if deterministic_timings or pr_timings:
+        pr_titles = {pr.number: pr.title for pr in candidate_prs}
+        # Amortize batch fetch time evenly across candidate PRs
+        num_candidates = len(candidate_prs) or 1
+        fetch_per_pr = (t_phase2b_end - t_phase2b_start + t_phase2c_end - 
t_phase2b_end) / num_candidates
+
+        action_styles = {
+            "drafted": "[yellow]drafted[/]",
+            "commented": "[yellow]commented[/]",
+            "closed": "[red]closed[/]",
+            "ready": "[success]ready[/]",
+            "skipped": "[dim]skipped[/]",
+            "approved": "[success]approved[/]",
+            "suspicious": "[red]suspicious[/]",
+        }
+
+        pr_timing_table = Table(title="Per-PR Phase Timing")
+        pr_timing_table.add_column("PR", style="bold")
+        pr_timing_table.add_column("Title")
+        pr_timing_table.add_column("Result")
+        pr_timing_table.add_column("Action")
+        pr_timing_table.add_column("Fetch (avg)", justify="right")
+        pr_timing_table.add_column("Deterministic", justify="right")
+        pr_timing_table.add_column("LLM", justify="right")
+        pr_timing_table.add_column("Total", justify="right")
+
+        # Collect all PR numbers that went through any phase
+        all_pr_numbers = sorted(
+            deterministic_timings.keys() | pr_timings.keys(),
+            key=lambda n: deterministic_timings.get(n, 0) + pr_timings.get(n, 
0) + fetch_per_pr,
+            reverse=True,
+        )
+        for pr_num in all_pr_numbers:
+            title = pr_titles.get(pr_num, "")[:60]
+            det_time = deterministic_timings.get(pr_num, 0)
+            llm_time = pr_timings.get(pr_num, 0)
+            total_time = fetch_per_pr + det_time + llm_time
+
+            if pr_num in assessments:
+                result = "[red]flagged[/]"
+            elif any(pr.number == pr_num for pr in passing_prs):
+                result = "[success]passed[/]"
+            elif any(pr.number == pr_num for pr in pending_approval):
+                result = "[dim]pending[/]"
+            else:
+                result = "[yellow]error[/]"
+
+            action_raw = pr_actions.get(pr_num, "")
+            action_display = action_styles.get(action_raw, f"[dim]{action_raw 
or '—'}[/]")
+
+            pr_timing_table.add_row(
+                f"#{pr_num}",
+                title,
+                result,
+                action_display,
+                _fmt_duration(fetch_per_pr),
+                _fmt_duration(det_time) if det_time else "[dim]—[/]",
+                _fmt_duration(llm_time) if llm_time else "[dim]—[/]",
+                _fmt_duration(total_time),
+            )
+        get_console().print(pr_timing_table)

Reply via email to