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()