1818import json
1919import logging
2020import os
21+ import re
2122import subprocess
2223import shlex
2324import sys
2425import tempfile
2526import textwrap
26- from typing import Dict , Optional , TextIO
27+ from typing import Dict , List , Optional , TextIO
2728
2829logger = logging .getLogger ()
2930logger .setLevel (logging .DEBUG )
@@ -103,6 +104,108 @@ def split_paragraphs(text: str):
103104 yield paragraph , markdown
104105
105106
107+ def resolve_reviewer (login : str ) -> tuple :
108+ """Map a GitHub login to (name, email).
109+
110+ Tries three tiers in order: repo commit history, GitHub user profile,
111+ and past `Reviewers:` trailers in git log (matched by name).
112+ Noreply emails (@users.noreply.github.com) are treated as missing since
113+ they are GitHub privacy placeholders that do not identify the reviewer.
114+ Returns (name, None) when no usable email is found; the caller falls
115+ back to the '(@login)' form in the Reviewers trailer.
116+ """
117+ def _usable_email (e ):
118+ if not e or e .endswith ("@users.noreply.github.com" ):
119+ return None
120+ return e
121+
122+ name = None
123+ email = None
124+
125+ # Tier 1: find from repo commit history. Misses when the reviewer has no
126+ # merged commit in apache/kafka, or had "Keep my email private" enabled
127+ # at commit time (GitHub rewrites the author to the noreply form).
128+ try :
129+ cmd = f"gh api repos/apache/kafka/commits?author={ login } &per_page=1"
130+ p = subprocess .run (shlex .split (cmd ), capture_output = True , text = True )
131+ if p .returncode == 0 :
132+ commits = json .loads (p .stdout )
133+ if commits :
134+ author = commits [0 ].get ("commit" , {}).get ("author" , {})
135+ name = author .get ("name" )
136+ email = _usable_email (author .get ("email" ))
137+ except Exception as e :
138+ logger .debug (f"Failed to resolve { login } from commit history: { e } " )
139+
140+ # Tier 2: GitHub user profile. Only exposes an email when the reviewer
141+ # has set a Public email in their profile settings.
142+ if not name or not email :
143+ try :
144+ cmd = f"gh api users/{ login } "
145+ p = subprocess .run (shlex .split (cmd ), capture_output = True , text = True )
146+ if p .returncode == 0 :
147+ user = json .loads (p .stdout )
148+ if not name :
149+ name = user .get ("name" )
150+ if not email :
151+ email = _usable_email (user .get ("email" ))
152+ except Exception as e :
153+ logger .debug (f"Failed to resolve { login } from GitHub profile: { e } " )
154+
155+ # Tier 3: past Reviewers: trailers in git log, matched by name. Catches
156+ # pure reviewers (no commits in apache/kafka, no public profile email)
157+ # who have been credited with a real email in an earlier merged PR.
158+ # git log is newest-first, so the first usable match is the most recent.
159+ if name and not email :
160+ try :
161+ p = subprocess .run (
162+ ["git" , "log" ,
163+ "--pretty=format:%(trailers:key=Reviewers,valueonly=true,unfold=true)" ],
164+ capture_output = True , text = True ,
165+ )
166+ if p .returncode == 0 :
167+ pattern = re .compile (rf"{ re .escape (name )} \s*<([^>]+)>" )
168+ for line in p .stdout .splitlines ():
169+ for m in pattern .finditer (line ):
170+ candidate = _usable_email (m .group (1 ))
171+ if candidate :
172+ email = candidate
173+ break
174+ if email :
175+ break
176+ except Exception as e :
177+ logger .debug (f"Failed to resolve { login } from past Reviewers trailers: { e } " )
178+
179+ if not name :
180+ name = login
181+
182+ return (name , email )
183+
184+
185+ def already_exists (identity : str , existing_reviewers : List [str ]) -> bool :
186+ """Check if a reviewer identity is already in the existing reviewers list.
187+
188+ identity is the delimited token that uniquely identifies a reviewer, either
189+ '<email>' (for the email form) or '(@login)' (for the login fallback).
190+ """
191+ return identity .lower () in ", " .join (existing_reviewers ).lower ()
192+
193+
194+ def update_reviewers_trailer (body : str , trailer : str ) -> str :
195+ """Update the Reviewers trailer in the body using git interpret-trailers."""
196+ with tempfile .NamedTemporaryFile () as fp :
197+ fp .write (body .strip ().encode ())
198+ fp .write (b"\n " )
199+ fp .flush ()
200+ cmd = f"git interpret-trailers --if-exists replace --trailer { shlex .quote (trailer )} { fp .name } "
201+ p = subprocess .run (shlex .split (cmd ), capture_output = True )
202+ fp .close ()
203+
204+ if p .returncode == 0 :
205+ return p .stdout .decode ()
206+ return body
207+
208+
106209if __name__ == "__main__" :
107210 """
108211 This script performs some basic linting of our PR titles and body. The PR number is read from the PR_NUMBER
@@ -123,7 +226,7 @@ def split_paragraphs(text: str):
123226 """
124227
125228 pr_number = get_env ("PR_NUMBER" )
126- cmd = f"gh pr view { pr_number } --json 'title,body,reviews'"
229+ cmd = f"gh pr view { pr_number } --json 'title,body,reviews,author '"
127230 p = subprocess .run (shlex .split (cmd ), capture_output = True )
128231 if p .returncode != 0 :
129232 logger .error (f"GitHub CLI failed with exit code { p .returncode } .\n STDOUT: { p .stdout .decode ()} \n STDERR:{ p .stderr .decode ()} " )
@@ -134,6 +237,23 @@ def split_paragraphs(text: str):
134237 body = gh_json ["body" ]
135238 reviews = gh_json ["reviews" ]
136239
240+ # Auto-fill reviewer from the current review event.
241+ # Approvals are also review events, so approvers are automatically added.
242+ reviewer_login = get_env ("REVIEWER_LOGIN" )
243+ pr_author = (gh_json .get ("author" ) or {}).get ("login" )
244+ if reviewer_login and reviewer_login != pr_author :
245+ name , email = resolve_reviewer (reviewer_login )
246+ if email :
247+ identity = f"<{ email } >"
248+ else :
249+ identity = f"(@{ reviewer_login } )"
250+ resolved = f"{ name } { identity } "
251+ existing_reviewers = parse_trailers (title , body ).get ("Reviewers" , [])
252+ if not already_exists (identity , existing_reviewers ):
253+ existing_value = ", " .join (existing_reviewers )
254+ new_value = f"{ existing_value } , { resolved } " if existing_value else resolved
255+ body = update_reviewers_trailer (body , f"Reviewers: { new_value } " )
256+
137257 checks = [] # (bool (0=ok, 1=error), message)
138258
139259 def check (positive_assertion , ok_msg , err_msg ):
0 commit comments