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 910ec333450 Batch workflow approval in pr auto-triage for faster 
review (#63771)
910ec333450 is described below

commit 910ec333450ae90eef841fb12feb738543a6622e
Author: Jarek Potiuk <[email protected]>
AuthorDate: Tue Mar 17 03:40:19 2026 +0100

    Batch workflow approval in pr auto-triage for faster review (#63771)
---
 .../src/airflow_breeze/commands/pr_commands.py     | 198 ++++++++++++++-------
 dev/breeze/src/airflow_breeze/utils/confirm.py     |  46 +++++
 2 files changed, 182 insertions(+), 62 deletions(-)

diff --git a/dev/breeze/src/airflow_breeze/commands/pr_commands.py 
b/dev/breeze/src/airflow_breeze/commands/pr_commands.py
index 945a38d7db7..efd6fa0fdd6 100644
--- a/dev/breeze/src/airflow_breeze/commands/pr_commands.py
+++ b/dev/breeze/src/airflow_breeze/commands/pr_commands.py
@@ -39,7 +39,14 @@ from airflow_breeze.commands.common_options import (
     option_verbose,
 )
 from airflow_breeze.utils.click_utils import BreezeGroup
-from airflow_breeze.utils.confirm import Answer, TriageAction, 
prompt_triage_action, user_confirm
+from airflow_breeze.utils.confirm import (
+    Answer,
+    ContinueAction,
+    TriageAction,
+    prompt_space_continue,
+    prompt_triage_action,
+    user_confirm,
+)
 from airflow_breeze.utils.console import console_print, get_console
 from airflow_breeze.utils.custom_param_types import 
HiddenChoiceWithCompletion, NotVerifiedBetterChoice
 from airflow_breeze.utils.run_utils import run_command
@@ -3441,6 +3448,9 @@ def _review_workflow_approval_prs(ctx: TriageContext, 
pending_approval: list[PRD
         f"{' (LLM assessments running in background)' if ctx.llm_future_to_pr 
else ''}:[/]\n"
     )
 
+    # Collect PRs with pending runs for batched review
+    batch_approvable: list[tuple[PRData, list[dict]]] = []
+
     for pr in pending_approval:
         if ctx.stats.quit_early:
             return
@@ -3692,20 +3702,50 @@ def _review_workflow_approval_prs(ctx: TriageContext, 
pending_approval: list[PRD
                 )
             continue
 
-        answer = user_confirm(
-            f"Review diff for PR {_pr_link(pr)} before approving workflows?",
-            default_answer=Answer.YES,
-            forced_answer=ctx.answer_triage,
+        # Normal workflow approval — collect for batched review
+        batch_approvable.append((pr, pending_runs))
+
+    # --- Batched workflow approval flow ---
+    # Group PRs that have pending runs, show titles first, then diffs 
one-by-one,
+    # then batch approve all non-flagged PRs at once.
+    if not batch_approvable or ctx.stats.quit_early:
+        return
+
+    console_print()
+    get_console().rule("[bold bright_cyan]Workflow approval — batch 
review[/]", style="bright_cyan")
+    console_print(
+        f"\n[info]{len(batch_approvable)} "
+        f"{'PRs' if len(batch_approvable) != 1 else 'PR'} "
+        f"with pending workflow runs to review:[/]\n"
+    )
+    for pr, runs in batch_approvable:
+        draft_tag = " [yellow](draft)[/]" if pr.is_draft else ""
+        console_print(
+            f"  {_pr_link(pr)} {pr.title}{draft_tag}  "
+            f"[dim]by {pr.author_login} — {len(runs)} pending "
+            f"{'runs' if len(runs) != 1 else 'run'}[/]"
         )
-        if answer == Answer.QUIT:
-            console_print("[warning]Quitting.[/]")
-            ctx.stats.quit_early = True
-            return
-        if answer == Answer.NO:
-            console_print(f"  [info]Skipping workflow approval for PR 
{_pr_link(pr)}.[/]")
-            continue
 
-        has_sensitive_changes = False
+    if ctx.dry_run:
+        console_print("\n[warning]Dry run — skipping batch workflow 
approval.[/]")
+        return
+
+    console_print(
+        "\n[info]Showing diffs one-by-one. Press SPACE to continue, "
+        "[f] to flag as suspicious, [q] to quit.[/]\n"
+    )
+
+    # Track which PRs to approve vs flagged as suspicious
+    prs_to_approve: list[tuple[PRData, list[dict]]] = []
+    flagged_suspicious: list[PRData] = []
+
+    for pr, runs in batch_approvable:
+        if ctx.stats.quit_early:
+            break
+
+        get_console().rule(f"[cyan]PR {_pr_link(pr)}[/]", style="dim")
+        console_print(f"  [bold]{pr.title}[/] by {pr.author_login}\n")
+
         console_print(f"  Fetching diff for PR {_pr_link(pr)}...")
         diff_text = _fetch_pr_diff(ctx.token, ctx.github_repository, pr.number)
         if diff_text:
@@ -3722,7 +3762,6 @@ def _review_workflow_approval_prs(ctx: TriageContext, 
pending_approval: list[PRD
             # Warn about changes to sensitive directories (.github/, scripts/)
             sensitive_files = _detect_sensitive_file_changes(diff_text)
             if sensitive_files:
-                has_sensitive_changes = True
                 console_print()
                 console_print(
                     "[bold red]WARNING: This PR contains changes to sensitive 
files "
@@ -3737,64 +3776,100 @@ def _review_workflow_approval_prs(ctx: TriageContext, 
pending_approval: list[PRD
                 f"Review manually at: {pr.url}/files[/]"
             )
 
-        approve_default = Answer.NO if has_sensitive_changes else Answer.YES
-        answer = user_confirm(
-            f"No suspicious changes found in PR {_pr_link(pr)}? "
-            f"Approve {len(pending_runs)} workflow {'runs' if 
len(pending_runs) != 1 else 'run'}?",
-            default_answer=approve_default,
+        action = prompt_space_continue(forced_answer=ctx.answer_triage)
+        if action == ContinueAction.QUIT:
+            console_print("[warning]Quitting.[/]")
+            ctx.stats.quit_early = True
+            break
+        if action == ContinueAction.FLAG:
+            console_print(f"  [bold red]Flagged PR {_pr_link(pr)} by 
{pr.author_login} as suspicious.[/]")
+            flagged_suspicious.append(pr)
+        else:
+            prs_to_approve.append((pr, runs))
+
+    if ctx.stats.quit_early:
+        return
+
+    # Handle flagged suspicious PRs
+    for pr in flagged_suspicious:
+        console_print(
+            f"\n  [bold red]Suspicious changes detected in PR {_pr_link(pr)} 
by {pr.author_login}.[/]"
+        )
+        console_print(f"  Fetching all open PRs by {pr.author_login}...")
+        author_prs = _fetch_author_open_prs(ctx.token, ctx.github_repository, 
pr.author_login)
+        if not author_prs:
+            console_print(f"  [dim]No open PRs found for 
{pr.author_login}.[/]")
+            continue
+
+        console_print()
+        console_print(
+            f"  [bold red]The following {len(author_prs)} "
+            f"{'PRs' if len(author_prs) != 1 else 'PR'} by "
+            f"{pr.author_login} will be closed, labeled "
+            f"'{_SUSPICIOUS_CHANGES_LABEL}', and commented:[/]"
+        )
+        for pr_info in author_prs:
+            console_print(f"    - 
[link={pr_info['url']}]#{pr_info['number']}[/link] {pr_info['title']}")
+        console_print()
+
+        confirm = user_confirm(
+            f"Close all {len(author_prs)} {'PRs' if len(author_prs) != 1 else 
'PR'} "
+            f"by {pr.author_login} and label as suspicious?",
             forced_answer=ctx.answer_triage,
         )
-        if answer == Answer.QUIT:
+        if confirm == Answer.QUIT:
             console_print("[warning]Quitting.[/]")
             ctx.stats.quit_early = True
             return
-        if answer == Answer.NO:
-            console_print(
-                f"\n  [bold red]Suspicious changes detected in PR 
{_pr_link(pr)} by {pr.author_login}.[/]"
-            )
-            console_print(f"  Fetching all open PRs by {pr.author_login}...")
-            author_prs = _fetch_author_open_prs(ctx.token, 
ctx.github_repository, pr.author_login)
-            if not author_prs:
-                console_print(f"  [dim]No open PRs found for 
{pr.author_login}.[/]")
-                continue
+        if confirm == Answer.NO:
+            console_print(f"  [info]Skipping — no PRs closed for 
{pr.author_login}.[/]")
+            continue
 
-            console_print()
-            console_print(
-                f"  [bold red]The following {len(author_prs)} "
-                f"{'PRs' if len(author_prs) != 1 else 'PR'} by "
-                f"{pr.author_login} will be closed, labeled "
-                f"'{_SUSPICIOUS_CHANGES_LABEL}', and commented:[/]"
-            )
-            for pr_info in author_prs:
-                console_print(f"    - 
[link={pr_info['url']}]#{pr_info['number']}[/link] {pr_info['title']}")
-            console_print()
+        closed, commented = _close_suspicious_prs(ctx.token, 
ctx.github_repository, author_prs, pr.number)
+        console_print(
+            f"  [success]Closed {closed}/{len(author_prs)} "
+            f"{'PRs' if len(author_prs) != 1 else 'PR'}, commented on 
{commented}.[/]"
+        )
+        ctx.stats.total_closed += closed
 
-            confirm = user_confirm(
-                f"Close all {len(author_prs)} {'PRs' if len(author_prs) != 1 
else 'PR'} "
-                f"by {pr.author_login} and label as suspicious?",
-                forced_answer=ctx.answer_triage,
-            )
-            if confirm == Answer.QUIT:
-                console_print("[warning]Quitting.[/]")
-                ctx.stats.quit_early = True
-                return
-            if confirm == Answer.NO:
-                console_print(f"  [info]Skipping — no PRs closed for 
{pr.author_login}.[/]")
-                continue
+    if ctx.stats.quit_early:
+        return
 
-            closed, commented = _close_suspicious_prs(ctx.token, 
ctx.github_repository, author_prs, pr.number)
-            console_print(
-                f"  [success]Closed {closed}/{len(author_prs)} "
-                f"{'PRs' if len(author_prs) != 1 else 'PR'}, commented on 
{commented}.[/]"
-            )
-            ctx.stats.total_closed += closed
-            continue
+    # Batch approve all non-flagged PRs
+    if not prs_to_approve:
+        console_print("\n[info]No PRs to approve (all were flagged or 
skipped).[/]")
+        return
+
+    console_print()
+    get_console().rule("[bold green]Batch approval[/]", style="green")
+    console_print(
+        f"\n[info]Approving workflows for {len(prs_to_approve)} "
+        f"{'PRs' if len(prs_to_approve) != 1 else 'PR'}:[/]"
+    )
+    for pr, runs in prs_to_approve:
+        console_print(
+            f"  {_pr_link(pr)} {pr.title} [dim]({len(runs)} {'runs' if 
len(runs) != 1 else 'run'})[/]"
+        )
+
+    answer = user_confirm(
+        f"Approve workflow runs for all {len(prs_to_approve)} {'PRs' if 
len(prs_to_approve) != 1 else 'PR'}?",
+        default_answer=Answer.YES,
+        forced_answer=ctx.answer_triage,
+    )
+    if answer == Answer.QUIT:
+        console_print("[warning]Quitting.[/]")
+        ctx.stats.quit_early = True
+        return
+    if answer == Answer.NO:
+        console_print("[info]Skipping batch approval — no workflows 
approved.[/]")
+        return
 
-        approved = _approve_workflow_runs(ctx.token, ctx.github_repository, 
pending_runs)
+    for pr, runs in prs_to_approve:
+        approved = _approve_workflow_runs(ctx.token, ctx.github_repository, 
runs)
         if approved:
             console_print(
-                f"  [success]Approved {approved}/{len(pending_runs)} workflow "
-                f"{'runs' if len(pending_runs) != 1 else 'run'} for PR "
+                f"  [success]Approved {approved}/{len(runs)} workflow "
+                f"{'runs' if len(runs) != 1 else 'run'} for PR "
                 f"{_pr_link(pr)}.[/]"
             )
             ctx.stats.total_workflows_approved += 1
@@ -3803,7 +3878,6 @@ def _review_workflow_approval_prs(ctx: TriageContext, 
pending_approval: list[PRD
             # Approval failed (likely 403 — runs are too old). Suggest 
converting to draft
             # with a rebase comment so the author pushes fresh commits.
             if pr.is_draft:
-                # Already a draft — just add a comment, no need to convert
                 rebase_comment = (
                     f"@{pr.author_login} This PR has workflow runs awaiting 
approval that could "
                     f"not be approved (they are likely too old). The PR is "
diff --git a/dev/breeze/src/airflow_breeze/utils/confirm.py 
b/dev/breeze/src/airflow_breeze/utils/confirm.py
index 0bd712b9d2a..a8603d3116b 100644
--- a/dev/breeze/src/airflow_breeze/utils/confirm.py
+++ b/dev/breeze/src/airflow_breeze/utils/confirm.py
@@ -211,6 +211,52 @@ def _show_pr_diff(token: str, github_repository: str, 
pr_number: int, pr_url: st
         console.print()
 
 
+class ContinueAction(Enum):
+    CONTINUE = "c"
+    FLAG = "f"
+    QUIT = "q"
+
+
+def prompt_space_continue(
+    message: str = "Press SPACE to continue, [f] to flag as suspicious, [q] to 
quit",
+    forced_answer: str | None = None,
+) -> ContinueAction:
+    """Wait for the user to press space/Enter to continue, 'f' to flag, or 'q' 
to quit.
+
+    Used for scrolling through diffs one-by-one without asking yes/no 
questions.
+    """
+    force = forced_answer or get_forced_answer() or os.environ.get("ANSWER")
+    if force:
+        upper = force.upper()
+        if upper in ("Q", "QUIT"):
+            return ContinueAction.QUIT
+        if upper in ("F", "FLAG"):
+            return ContinueAction.FLAG
+        return ContinueAction.CONTINUE
+
+    console_print(f"\n{message}: ", end="")
+
+    while True:
+        try:
+            ch = _read_char()
+        except (KeyboardInterrupt, EOFError):
+            console_print()
+            return ContinueAction.QUIT
+
+        if len(ch) > 1:
+            continue
+
+        if ch in (" ", "\r", "\n", ""):
+            console_print()
+            return ContinueAction.CONTINUE
+        if ch.upper() == "F":
+            console_print("flag")
+            return ContinueAction.FLAG
+        if ch.upper() == "Q":
+            console_print("quit")
+            return ContinueAction.QUIT
+
+
 def prompt_triage_action(
     message: str,
     default: TriageAction = TriageAction.DRAFT,

Reply via email to