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 bee9f48422b Add contributor scoring and author info overlay to 
auto-triage TUI (#64569)
bee9f48422b is described below

commit bee9f48422b33814a81913f4d95ae26f343c9f61
Author: André Ahlert <[email protected]>
AuthorDate: Wed Apr 1 09:01:56 2026 -0300

    Add contributor scoring and author info overlay to auto-triage TUI (#64569)
    
    Derive merge rate, contributor tier, and risk level from
    existing GraphQL data (zero extra API calls) and display
    them in both the sequential review panel and the TUI
    detail panel. Add an "i" key overlay that shows the full
    contributor profile with PR stats, scoring, and contributed
    repos.
    
    Signed-off-by: André Ahlert <[email protected]>
---
 .../src/airflow_breeze/commands/pr_commands.py     | 154 +++++++++++++++++++-
 dev/breeze/src/airflow_breeze/utils/tui_display.py | 161 ++++++++++++++++++++-
 2 files changed, 307 insertions(+), 8 deletions(-)

diff --git a/dev/breeze/src/airflow_breeze/commands/pr_commands.py 
b/dev/breeze/src/airflow_breeze/commands/pr_commands.py
index 1214cbc2cc5..d25e90257fc 100644
--- a/dev/breeze/src/airflow_breeze/commands/pr_commands.py
+++ b/dev/breeze/src/airflow_breeze/commands/pr_commands.py
@@ -1770,6 +1770,76 @@ def _fetch_single_pr_graphql(token: str, 
github_repository: str, pr_number: int)
 _author_profile_cache: dict[str, dict] = {}
 
 
+def _compute_author_scoring(
+    repo_total: int,
+    repo_merged: int,
+    repo_closed: int,
+    global_total: int,
+    global_merged: int,
+    global_closed: int,
+    created_at: str,
+    contributed_repos_total: int,
+) -> dict:
+    """Derive scoring fields from raw PR counts.
+
+    Returns a dict with merge rates, contributor tier, and risk level
+    that gets merged into the author profile.
+    """
+    from datetime import datetime, timezone
+
+    repo_merge_rate = repo_merged / repo_total if repo_total > 0 else 0.0
+    global_merge_rate = global_merged / global_total if global_total > 0 else 
0.0
+
+    # Account age in days
+    account_age_days = 0
+    if created_at and created_at != "unknown":
+        try:
+            created_dt = datetime.fromisoformat(created_at.replace("Z", 
"+00:00"))
+            account_age_days = (datetime.now(timezone.utc) - created_dt).days
+        except (ValueError, TypeError):
+            pass
+
+    # Contributor tier based on repo history
+    if repo_merged >= 10:
+        tier = "established"
+    elif repo_merged >= 3:
+        tier = "regular"
+    elif repo_merged >= 1:
+        tier = "occasional"
+    elif repo_total > 0:
+        tier = "attempted"
+    else:
+        tier = "new"
+
+    # Risk level for triage prioritization
+    risk_signals = 0
+    if account_age_days < 30:
+        risk_signals += 2
+    elif account_age_days < 90:
+        risk_signals += 1
+    if repo_total > 0 and repo_merge_rate < 0.3:
+        risk_signals += 2
+    if repo_total == 0 and global_total > 5 and global_merge_rate < 0.2:
+        risk_signals += 1
+    if contributed_repos_total == 0:
+        risk_signals += 1
+
+    if risk_signals >= 3:
+        risk = "high"
+    elif risk_signals >= 1:
+        risk = "medium"
+    else:
+        risk = "low"
+
+    return {
+        "repo_merge_rate": round(repo_merge_rate, 2),
+        "global_merge_rate": round(global_merge_rate, 2),
+        "account_age_days": account_age_days,
+        "contributor_tier": tier,
+        "risk_level": risk,
+    }
+
+
 def _fetch_author_profile(token: str, login: str, github_repository: str) -> 
dict:
     """Fetch author profile info via GraphQL: account age, PR counts, 
contributed repos.
 
@@ -1828,18 +1898,35 @@ def _fetch_author_profile(token: str, login: str, 
github_repository: str) -> dic
                 }
             )
 
+    repo_total = data.get("repoAll", {}).get("issueCount", 0)
+    repo_merged = data.get("repoMerged", {}).get("issueCount", 0)
+    repo_closed = data.get("repoClosed", {}).get("issueCount", 0)
+    global_total = data.get("globalAll", {}).get("issueCount", 0)
+    global_merged = data.get("globalMerged", {}).get("issueCount", 0)
+    global_closed = data.get("globalClosed", {}).get("issueCount", 0)
+
     profile = {
         "login": login,
         "account_age": account_age,
         "created_at": created_at,
-        "repo_total_prs": data.get("repoAll", {}).get("issueCount", 0),
-        "repo_merged_prs": data.get("repoMerged", {}).get("issueCount", 0),
-        "repo_closed_prs": data.get("repoClosed", {}).get("issueCount", 0),
-        "global_total_prs": data.get("globalAll", {}).get("issueCount", 0),
-        "global_merged_prs": data.get("globalMerged", {}).get("issueCount", 0),
-        "global_closed_prs": data.get("globalClosed", {}).get("issueCount", 0),
+        "repo_total_prs": repo_total,
+        "repo_merged_prs": repo_merged,
+        "repo_closed_prs": repo_closed,
+        "global_total_prs": global_total,
+        "global_merged_prs": global_merged,
+        "global_closed_prs": global_closed,
         "contributed_repos": contributed_repos,
         "contributed_repos_total": contrib_total,
+        **_compute_author_scoring(
+            repo_total,
+            repo_merged,
+            repo_closed,
+            global_total,
+            global_merged,
+            global_closed,
+            created_at,
+            contrib_total,
+        ),
     }
     _author_profile_cache[login] = profile
     return profile
@@ -2348,6 +2435,37 @@ def _display_pr_info_panels(
             ),
         ]
 
+        # Scoring: merge rate, tier, risk
+        tier = author_profile.get("contributor_tier", "")
+        risk = author_profile.get("risk_level", "")
+        repo_rate = author_profile.get("repo_merge_rate", 0)
+        global_rate = author_profile.get("global_merge_rate", 0)
+
+        tier_colors = {
+            "established": "green",
+            "regular": "cyan",
+            "occasional": "yellow",
+            "attempted": "red",
+            "new": "red",
+        }
+        tier_color = tier_colors.get(tier, "dim")
+
+        risk_colors = {"low": "green", "medium": "yellow", "high": "red"}
+        risk_color = risk_colors.get(risk, "dim")
+
+        scoring_parts = []
+        if tier:
+            scoring_parts.append(f"Tier: [{tier_color}]{tier}[/]")
+        if repo_rate > 0:
+            rate_color = "green" if repo_rate >= 0.5 else "yellow" if 
repo_rate >= 0.3 else "red"
+            scoring_parts.append(
+                f"Merge rate: [{rate_color}]{repo_rate:.0%}[/] repo, 
{global_rate:.0%} global"
+            )
+        if risk:
+            scoring_parts.append(f"Risk: [{risk_color}]{risk}[/]")
+        if scoring_parts:
+            lines.append(" | ".join(scoring_parts))
+
         contributed_repos = author_profile.get("contributed_repos", [])
         contrib_total = author_profile.get("contributed_repos_total", 0)
         if contributed_repos:
@@ -4586,7 +4704,7 @@ def _run_tui_triage(
             _apply_refresh_results()
             continue
 
-        # Auto-fetch diff when cursor moves to a different PR (non-blocking)
+        # Auto-fetch diff and author scoring when cursor moves to a different 
PR
         if tui.cursor_changed() and tui.needs_diff_fetch():
             current_entry = tui.get_selected_entry()
             if current_entry:
@@ -4594,6 +4712,22 @@ def _run_tui_triage(
                 # Prefetch a few PRs ahead of the cursor
                 for i in range(tui.cursor + 1, min(tui.cursor + 4, 
len(entries))):
                     _submit_diff_fetch(entries[i].pr.number, entries[i].pr.url)
+                # Populate author scoring for the detail panel
+                if current_entry.author_scoring is None:
+                    profile = _fetch_author_profile(
+                        ctx.token, current_entry.pr.author_login, 
ctx.github_repository
+                    )
+                    current_entry.author_scoring = {
+                        k: profile[k]
+                        for k in (
+                            "repo_merge_rate",
+                            "global_merge_rate",
+                            "account_age_days",
+                            "contributor_tier",
+                            "risk_level",
+                        )
+                        if k in profile
+                    }
 
         if action == TUIAction.QUIT:
             tui.disable_mouse()
@@ -4749,6 +4883,12 @@ def _run_tui_triage(
             ctx.stats.total_skipped_action += 1
             continue
 
+        # Author info overlay
+        if action == TUIAction.ACTION_AUTHOR_INFO:
+            profile = _fetch_author_profile(ctx.token, pr.author_login, 
ctx.github_repository)
+            tui.render_author_overlay(profile)
+            continue
+
         # Direct triage actions from TUI (without entering detailed review)
         if isinstance(action, TUIAction) and action.name.startswith("ACTION_"):
             tui.disable_mouse()
diff --git a/dev/breeze/src/airflow_breeze/utils/tui_display.py 
b/dev/breeze/src/airflow_breeze/utils/tui_display.py
index 5ec839fb16d..c4027b786ab 100644
--- a/dev/breeze/src/airflow_breeze/utils/tui_display.py
+++ b/dev/breeze/src/airflow_breeze/utils/tui_display.py
@@ -114,6 +114,7 @@ class TUIAction(Enum):
     ACTION_READY = "ready"
     ACTION_FLAG = "flag"
     ACTION_LLM = "llm"
+    ACTION_AUTHOR_INFO = "author_info"
 
 
 class _FocusPanel(Enum):
@@ -325,6 +326,8 @@ def _read_tui_key(*, timeout: float | None = None) -> 
TUIAction | MouseEvent | s
         return TUIAction.ACTION_RERUN
     if ch == "l":
         return TUIAction.ACTION_LLM
+    if ch == "i":
+        return TUIAction.ACTION_AUTHOR_INFO
     # Ctrl-C
     if ch == "\x03":
         return TUIAction.QUIT
@@ -350,6 +353,8 @@ class PRListEntry:
         self.llm_submit_time: float = 0.0  # monotonic time when actually 
started running
         self.llm_duration: float = 0.0  # actual measured LLM execution time 
in seconds
         self.llm_attempts: int = 0  # number of LLM attempts (including 
retries)
+        # Author scoring (populated when author profile is fetched)
+        self.author_scoring: dict | None = None
 
 
 class TriageTUI:
@@ -785,9 +790,33 @@ class TriageTUI:
 
         # Author
         author_url = 
f"https://github.com/{self.github_repository}/pulls/{pr.author_login}";
-        lines.append(
+        author_line = (
             f"Author: [bold][link={author_url}]{pr.author_login}[/link][/] 
([dim]{pr.author_association}[/])"
         )
+        scoring = entry.author_scoring if entry else None
+        if scoring:
+            tier = scoring.get("contributor_tier", "")
+            risk = scoring.get("risk_level", "")
+            tier_colors = {
+                "established": "green",
+                "regular": "cyan",
+                "occasional": "yellow",
+                "attempted": "red",
+                "new": "red",
+            }
+            risk_colors = {"low": "green", "medium": "yellow", "high": "red"}
+            parts = []
+            if tier:
+                parts.append(f"[{tier_colors.get(tier, 'dim')}]{tier}[/]")
+            if risk and risk != "low":
+                parts.append(f"risk:[{risk_colors.get(risk, 'dim')}]{risk}[/]")
+            repo_rate = scoring.get("repo_merge_rate", 0)
+            if repo_rate > 0:
+                rc = "green" if repo_rate >= 0.5 else "yellow" if repo_rate >= 
0.3 else "red"
+                parts.append(f"[{rc}]{repo_rate:.0%}[/] merged")
+            if parts:
+                author_line += f"  {' | '.join(parts)}"
+        lines.append(author_line)
 
         # Timestamps
         lines.append(
@@ -1264,6 +1293,7 @@ class TriageTUI:
             direct_parts: list[str] = []
             direct_parts.append("[bold]o[/] Open")
             direct_parts.append("[bold]s[/] Skip")
+            direct_parts.append("[bold]i[/] Author")
             available = self.get_available_actions(entry)
             if available:
                 if self.review_mode:
@@ -1548,6 +1578,126 @@ class TriageTUI:
         # Ignore other mouse events
         return None
 
+    def render_author_overlay(self, profile: dict) -> None:
+        """Render a full-screen overlay with detailed author information.
+
+        Blocks until the user presses any key to dismiss.
+        """
+        from rich.console import Console
+        from rich.panel import Panel
+
+        width, height = _get_terminal_size()
+
+        login = profile.get("login", "unknown")
+        tier = profile.get("contributor_tier", "")
+        risk = profile.get("risk_level", "")
+        repo_rate = profile.get("repo_merge_rate", 0)
+        global_rate = profile.get("global_merge_rate", 0)
+        age_days = profile.get("account_age_days", 0)
+
+        tier_colors = {
+            "established": "green",
+            "regular": "cyan",
+            "occasional": "yellow",
+            "attempted": "red",
+            "new": "red",
+        }
+        risk_colors = {"low": "green", "medium": "yellow", "high": "red"}
+
+        lines: list[str] = []
+        lines.append(f"[bold]Account age:[/] {profile.get('account_age', 
'unknown')} ({age_days} days)")
+        lines.append("")
+
+        # PR stats table
+        repo_total = profile.get("repo_total_prs", 0)
+        repo_merged = profile.get("repo_merged_prs", 0)
+        repo_closed = profile.get("repo_closed_prs", 0)
+        rc = "green" if repo_rate >= 0.5 else "yellow" if repo_rate >= 0.3 
else "red"
+
+        global_total = profile.get("global_total_prs", 0)
+        global_merged = profile.get("global_merged_prs", 0)
+        global_closed = profile.get("global_closed_prs", 0)
+        gc = "green" if global_rate >= 0.5 else "yellow" if global_rate >= 0.3 
else "red"
+
+        lines.append(
+            f"[bold]This repo:[/]  {repo_total} PRs, "
+            f"[green]{repo_merged} merged[/], "
+            f"[red]{repo_closed} closed[/], "
+            f"rate [{rc}]{repo_rate:.0%}[/]"
+        )
+        lines.append(
+            f"[bold]All GitHub:[/] {global_total} PRs, "
+            f"[green]{global_merged} merged[/], "
+            f"[red]{global_closed} closed[/], "
+            f"rate [{gc}]{global_rate:.0%}[/]"
+        )
+        lines.append("")
+
+        # Scoring
+        tc = tier_colors.get(tier, "dim")
+        rkc = risk_colors.get(risk, "dim")
+        lines.append(f"[bold]Contributor tier:[/] [{tc}]{tier}[/]")
+        lines.append(f"[bold]Risk level:[/] [{rkc}]{risk}[/]")
+        lines.append("")
+
+        # Contributed repos
+        repos = profile.get("contributed_repos", [])
+        contrib_total = profile.get("contributed_repos_total", 0)
+        if repos:
+            lines.append(f"[bold]Contributed to ({contrib_total} repos):[/]")
+            for repo in repos:
+                stars = repo.get("stars", 0)
+                star_text = f" ({stars} stars)" if stars else ""
+                lines.append(f"  
[link={repo['url']}]{repo['name']}[/link]{star_text}")
+        else:
+            lines.append("[dim]No public repo contributions found[/]")
+
+        lines.append("")
+        lines.append("[dim]Press any key to close[/]")
+
+        author_url = f"https://github.com/{login}";
+        panel_width = min(width - 8, 70)
+        panel_height = min(height - 6, len(lines) + 4)
+        panel = Panel(
+            "\n".join(lines),
+            title=f"[bold][link={author_url}]{login}[/link][/] — Contributor 
Profile",
+            border_style=self._accent_bold,
+            height=panel_height,
+            width=panel_width,
+        )
+
+        # Render the panel into a string buffer
+        buf = io.StringIO()
+        buf_console = Console(
+            file=buf, force_terminal=True, color_system="standard", 
width=panel_width, theme=get_theme()
+        )
+        buf_console.print(panel)
+        panel_lines = buf.getvalue().split("\n")
+
+        # Calculate centering offsets
+        top_offset = max(1, (height - panel_height) // 2)
+        left_offset = max(1, (width - panel_width) // 2)
+        # Background fill: blank line the full width of the overlay area
+        blank_line = " " * (panel_width + 2)
+
+        # Save cursor, hide it, then draw overlay lines at centered position
+        out = "\033[?25l"  # hide cursor
+        # First paint a solid background block to cover TUI content behind
+        for row in range(top_offset, top_offset + panel_height + 1):
+            out += f"\033[{row};{left_offset}H{blank_line}"
+        # Then draw the panel lines on top
+        for i, pline in enumerate(panel_lines):
+            if pline:
+                row = top_offset + i
+                out += f"\033[{row};{left_offset}H {pline}"
+        sys.stdout.write(out)
+        sys.stdout.flush()
+
+        # Wait for any key to dismiss
+        _read_raw_input(timeout=None)
+        sys.stdout.write("\033[?25h")  # restore cursor
+        sys.stdout.flush()
+
     def run_interactive(
         self, *, timeout: float | None = None
     ) -> tuple[PRListEntry | None, TUIAction | str | None]:
@@ -1615,6 +1765,9 @@ class TriageTUI:
                 if selected:
                     return selected[0], key
                 return None, key
+            # Author info — always available
+            if key == TUIAction.ACTION_AUTHOR_INFO:
+                return self.get_selected_entry(), key
             # Direct action keys — pass through if available for this PR
             if isinstance(key, TUIAction) and key.name.startswith("ACTION_"):
                 entry = self.get_selected_entry()
@@ -1661,6 +1814,9 @@ class TriageTUI:
                 if selected:
                     return selected[0], key
                 return None, key
+            # Author info — always available
+            if key == TUIAction.ACTION_AUTHOR_INFO:
+                return self.get_selected_entry(), key
             # Direct action keys
             if isinstance(key, TUIAction) and key.name.startswith("ACTION_"):
                 entry = self.get_selected_entry()
@@ -1717,6 +1873,9 @@ class TriageTUI:
                 entry.action_taken = "skipped"
                 self.move_cursor(1)
             return entry, key
+        # Author info — always available
+        if key == TUIAction.ACTION_AUTHOR_INFO:
+            return self.get_selected_entry(), key
         # Direct action keys — pass through if available for this PR
         if isinstance(key, TUIAction) and key.name.startswith("ACTION_"):
             entry = self.get_selected_entry()

Reply via email to