This is an automated email from the ASF dual-hosted git repository.

chia7712 pushed a commit to branch trunk
in repository https://gitbox.apache.org/repos/asf/kafka.git


The following commit(s) were added to refs/heads/trunk by this push:
     new ef5c8dc9950 KAFKA-20362 Make reviewer-trailer auto-fill work on all 
contributors and comments (#22475)
ef5c8dc9950 is described below

commit ef5c8dc99503176e0d7f5eb5068372d12f04d9df
Author: Ming-Yen Chung <[email protected]>
AuthorDate: Sat Jun 13 10:57:40 2026 +0800

    KAFKA-20362 Make reviewer-trailer auto-fill work on all contributors and 
comments (#22475)
    
    #21928 automatically appends reviewers to the `Reviewers:` trailer, but
    only when the PR is from a committer/collaborator. On anyone else's PR,
    the `pull_request_review` run is gated and needs a maintainer to approve
    it (per ASF policy, the default is to ["always require approval for
    external
    contributors"](https://infra.apache.org/github-actions-policy.html)).
    
    Now a comment, a review-with-comment, or a plain Approve all
    automatically add the reviewer to the `Reviewers:` trailer — for anyone,
    no approval needed. Two things make this work:
    
    - The on-review workflow triggers on the `workflow_run` of "Pull Request
    Reviewed" with `types: [requested]`, not `completed`. The gated run
    never completes, but `requested` fires the moment it's queued, so the
    gate never blocks us.
    - `workflow_run` doesn't carry the PR number for fork PRs, so we recover
    it by searching for the open PR whose head matches the run's `head_sha`.
    
    The logic moves to a dedicated `pr-reviewers-trailer.py`; the trailer
    code #21928 added to `pr-format.py`, `pr-reviewed.yml`, and
    `pr-linter.yml` is reverted, leaving them as pure linting.
    
    Try it: review or comment on https://github.com/mingyen066/kafka/pull/9
    and check that you land in that PR's `Reviewers:` trailer.
    
    Reviewers: Chia-Ping Tsai <[email protected]>
---
 .github/scripts/pr-format.py                       | 141 +--------------
 .../{pr-format.py => pr-reviewers-trailer.py}      | 190 +++++----------------
 .github/workflows/pr-linter.yml                    |   7 +-
 .github/workflows/pr-reviewed.yml                  |   4 -
 .../workflows/pr-reviewers-trailer-on-comment.yml  |  57 +++++++
 .../workflows/pr-reviewers-trailer-on-review.yml   |  77 +++++++++
 6 files changed, 181 insertions(+), 295 deletions(-)

diff --git a/.github/scripts/pr-format.py b/.github/scripts/pr-format.py
index e2ad6a292fd..d2da5e3e5bf 100644
--- a/.github/scripts/pr-format.py
+++ b/.github/scripts/pr-format.py
@@ -18,13 +18,12 @@ from io import BytesIO
 import json
 import logging
 import os
-import re
 import subprocess
 import shlex
 import sys
 import tempfile
 import textwrap
-from typing import Dict, List, Optional, TextIO
+from typing import Dict, Optional, TextIO
 
 logger = logging.getLogger()
 logger.setLevel(logging.DEBUG)
@@ -104,124 +103,6 @@ def split_paragraphs(text: str):
     yield paragraph, markdown
 
 
-def resolve_reviewer(login: str) -> tuple:
-    """Map a GitHub login to (name, email).
-
-    Tries reviewer email sources in order: repo commit author email, past
-    `Reviewers:` trailers searched via GitHub commit search API (matched
-    by name and verified by PR review login), and GitHub user profile
-    public email. Noreply emails (@users.noreply.github.com) are treated
-    as missing since they are GitHub privacy placeholders that do not
-    identify the reviewer. Returns (name, None) when no usable email is
-    found; the caller falls back to the '(github:login)' form in the
-    Reviewers trailer.
-    """
-    def _usable_email(e):
-        if not e or e.endswith("@users.noreply.github.com"):
-            return None
-        return e
-
-    def _run_json(cmd, source):
-        try:
-            p = subprocess.run(cmd, capture_output=True, text=True)
-            if p.returncode == 0:
-                return json.loads(p.stdout)
-            logger.debug(f"Failed to resolve {login} from {source}: 
{p.stderr}")
-        except Exception as e:
-            logger.debug(f"Failed to resolve {login} from {source}: {e}")
-        return None
-
-    def _has_pr_review_from_login(commit_sha):
-        pulls = _run_json(["gh", "api", 
f"repos/apache/kafka/commits/{commit_sha}/pulls"],
-                          f"associated PRs for commit {commit_sha}") or []
-        for pull in pulls:
-            pr_number = pull.get("number")
-            if not pr_number:
-                continue
-            reviews = _run_json(["gh", "api", 
f"repos/apache/kafka/pulls/{pr_number}/reviews?per_page=100"],
-                                f"reviews for PR {pr_number}") or []
-            if any((review.get("user") or {}).get("login", "").lower() == 
login.lower()
-                   for review in reviews):
-                return True
-        return False
-
-    commits = _run_json(["gh", "api", 
f"repos/apache/kafka/commits?author={login}&per_page=1"],
-                        "commit history") or []
-    author = commits[0].get("commit", {}).get("author", {}) if commits else {}
-
-    # Tier 1: latest repo commit authored by this GitHub login. Misses
-    # when the reviewer has no merged commit in apache/kafka, or had
-    # "Keep my email private" enabled at commit time (GitHub rewrites
-    # the author to the noreply form).
-    email = _usable_email(author.get("email"))
-    if email:
-        return (author.get("name") or login, email)
-
-    user = _run_json(["gh", "api", f"users/{login}"], "GitHub profile") or {}
-
-    name_candidates = []
-    for candidate in (user.get("name"), author.get("name"), login):
-        if candidate and candidate not in name_candidates:
-            name_candidates.append(candidate)
-
-    name = name_candidates[0] if name_candidates else login
-
-    # Tier 2: past Reviewers: trailers in commit history, matched by name,
-    # via the GitHub commit search API. Catches pure reviewers (no commits
-    # in apache/kafka, no public profile email) who have been credited
-    # with a real email in an earlier merged PR. Sort by committer-date
-    # desc so the most recent email wins if a reviewer has changed it.
-    # Full-text search is tokenized (not strict substring), so we re-verify
-    # with a regex client-side. To avoid same-name matches, we only accept
-    # a trailer email when the matched commit's associated PR includes a
-    # review from this GitHub login.
-    for candidate in name_candidates:
-        results = _run_json(["gh", "search", "commits",
-                             "--repo", "apache/kafka",
-                             f'"{candidate} <"',
-                             "--limit", "10",
-                             "--sort", "committer-date",
-                             "--order", "desc",
-                             "--json", "sha,commit"],
-                            "commit search") or []
-        pattern = re.compile(rf"{re.escape(candidate)}\s*<([^>]+)>")
-        for result in results:
-            msg = result.get("commit", {}).get("message", "")
-            commit_sha = result.get("sha")
-            for match in pattern.finditer(msg):
-                candidate_email = _usable_email(match.group(1))
-                if candidate_email and commit_sha and 
_has_pr_review_from_login(commit_sha):
-                    return (candidate, candidate_email)
-
-    # Tier 3: GitHub user profile. Only exposes an email when the reviewer
-    # has set a Public email in their profile settings.
-    return (name, _usable_email(user.get("email")))
-
-
-def already_exists(identity: str, existing_reviewers: List[str]) -> bool:
-    """Check if a reviewer identity is already in the existing reviewers list.
-
-    identity is the delimited token that uniquely identifies a reviewer, either
-    '<email>' (for the email form) or '(github:login)' (for the login 
fallback).
-    """
-    return identity.lower() in ", ".join(existing_reviewers).lower()
-
-
-def update_reviewers_trailer(body: str, trailer: str) -> str:
-    """Update the Reviewers trailer in the body using git 
interpret-trailers."""
-    with tempfile.NamedTemporaryFile() as fp:
-        fp.write(body.strip().encode())
-        fp.write(b"\n")
-        fp.flush()
-        cmd = f"git interpret-trailers --if-exists replace --trailer 
{shlex.quote(trailer)} {fp.name}"
-        p = subprocess.run(shlex.split(cmd), capture_output=True)
-        fp.close()
-
-    if p.returncode == 0:
-        return p.stdout.decode()
-    return body
-
-
 if __name__ == "__main__":
     """
     This script performs some basic linting of our PR titles and body. The PR 
number is read from the PR_NUMBER
@@ -242,7 +123,7 @@ if __name__ == "__main__":
     """
 
     pr_number = get_env("PR_NUMBER")
-    cmd = f"gh pr view {pr_number} --json 'title,body,reviews,author'"
+    cmd = f"gh pr view {pr_number} --json 'title,body,reviews'"
     p = subprocess.run(shlex.split(cmd), capture_output=True)
     if p.returncode != 0:
         logger.error(f"GitHub CLI failed with exit code 
{p.returncode}.\nSTDOUT: {p.stdout.decode()}\nSTDERR:{p.stderr.decode()}")
@@ -253,24 +134,6 @@ if __name__ == "__main__":
     body = gh_json["body"]
     reviews = gh_json["reviews"]
 
-    # Auto-fill reviewer from the current review event.
-    # Approvals are also review events, so approvers are automatically added.
-    reviewer_login = get_env("REVIEWER_LOGIN")
-    pr_author = (gh_json.get("author") or {}).get("login")
-    if reviewer_login and reviewer_login != pr_author:
-        name, email = resolve_reviewer(reviewer_login)
-        if email:
-            identity = f"<{email}>"
-        else:
-            # Tier 4: fall back to the GitHub handle without tagging the 
reviewer.
-            identity = f"(github:{reviewer_login})"
-        resolved = f"{name} {identity}"
-        existing_reviewers = parse_trailers(title, body).get("Reviewers", [])
-        if not already_exists(identity, existing_reviewers):
-            existing_value = ", ".join(existing_reviewers)
-            new_value = f"{existing_value}, {resolved}" if existing_value else 
resolved
-            body = update_reviewers_trailer(body, f"Reviewers: {new_value}")
-
     checks = [] # (bool (0=ok, 1=error), message)
 
     def check(positive_assertion, ok_msg, err_msg):
diff --git a/.github/scripts/pr-format.py 
b/.github/scripts/pr-reviewers-trailer.py
similarity index 58%
copy from .github/scripts/pr-format.py
copy to .github/scripts/pr-reviewers-trailer.py
index e2ad6a292fd..d32039c371d 100644
--- a/.github/scripts/pr-format.py
+++ b/.github/scripts/pr-reviewers-trailer.py
@@ -13,8 +13,18 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+# Appends a reviewer to the `Reviewers:` trailer of a PR body. This is the
+# shared engine behind the pr-reviewers-trailer-on-review.yml and
+# pr-reviewers-trailer-on-comment.yml workflows. It is intentionally separate
+# from pr-format.py (the PR linter): those workflows only want to credit a
+# reviewer, not run the title/body lint, so coupling the two would surface
+# spurious lint failures and rewrite the whole body on every review/comment.
+#
+# Reads PR_NUMBER and REVIEWER_LOGIN from the environment. Expects the `gh`
+# CLI and `git` to be available. No-op (exit 0) when REVIEWER_LOGIN is unset
+# or equal to the PR author.
+
 from collections import defaultdict
-from io import BytesIO
 import json
 import logging
 import os
@@ -23,7 +33,6 @@ import subprocess
 import shlex
 import sys
 import tempfile
-import textwrap
 from typing import Dict, List, Optional, TextIO
 
 logger = logging.getLogger()
@@ -32,9 +41,6 @@ handler = logging.StreamHandler(sys.stderr)
 handler.setLevel(logging.DEBUG)
 logger.addHandler(handler)
 
-ok = "✅"
-err = "❌"
-
 
 def get_env(key: str, fn = str) -> Optional:
     value = os.getenv(key)
@@ -46,16 +52,6 @@ def get_env(key: str, fn = str) -> Optional:
         return fn(value)
 
 
-def has_approval(reviews) -> bool:
-    approved = False
-    for review in reviews:
-        if review.get("authorAssociation") not in ("MEMBER", "OWNER"):
-            continue
-        if review.get("state") == "APPROVED":
-            approved = True
-    return approved
-
-
 def write_commit(io: TextIO, title: str, body: str):
     io.write(title.encode())
     io.write(b"\n\n")
@@ -79,31 +75,6 @@ def parse_trailers(title, body) -> Dict:
     return trailers
 
 
-def split_paragraphs(text: str):
-    """
-    Split the given text into a generator of paragraph lines and a boolean 
"markdown" flag.
-
-    If any line of a paragraph starts with a markdown character, we will 
assume the whole paragraph
-    contains markdown.
-    """
-    lines = text.splitlines(keepends=True)
-    paragraph = []
-    markdown = False
-    for line in lines:
-        if line.strip() == "":
-            if len(paragraph) > 0:
-                yield paragraph, markdown
-                paragraph.clear()
-                markdown = False
-        else:
-            if line[0] in ("#", "*", "-", "=") or line[0].isdigit():
-                markdown = True
-            if "```" in line:
-                markdown = True
-            paragraph.append(line)
-    yield paragraph, markdown
-
-
 def resolve_reviewer(login: str) -> tuple:
     """Map a GitHub login to (name, email).
 
@@ -224,25 +195,17 @@ def update_reviewers_trailer(body: str, trailer: str) -> 
str:
 
 if __name__ == "__main__":
     """
-    This script performs some basic linting of our PR titles and body. The PR 
number is read from the PR_NUMBER
-    environment variable. Since this script expects to run on a GHA runner, it 
expects the "gh" tool to be installed.
-    
-    The STDOUT from this script is used as the status check message. It should 
not be too long. Use the logger for
-    any necessary logging.
-    
-    Title checks:
-    * Not too short (at least 15 characters)
-    * Not too long (at most 120 characters)
-    * Not truncated (ending with ...)
-    * Starts with "KAFKA-", "MINOR", or "HOTFIX"
-    
-    Body checks:
-    * Is not empty
-    * Has "Reviewers:" trailer if the PR is approved
+    Appends REVIEWER_LOGIN to the Reviewers trailer of PR_NUMBER's body.
+    Approvals are review events too, so approvers are credited the same way.
+    The PR author is never added as their own reviewer.
     """
-
     pr_number = get_env("PR_NUMBER")
-    cmd = f"gh pr view {pr_number} --json 'title,body,reviews,author'"
+    reviewer_login = get_env("REVIEWER_LOGIN")
+    if not pr_number or not reviewer_login:
+        logger.info("PR_NUMBER and REVIEWER_LOGIN are both required; nothing 
to do.")
+        exit(0)
+
+    cmd = f"gh pr view {pr_number} --json 'title,body,author'"
     p = subprocess.run(shlex.split(cmd), capture_output=True)
     if p.returncode != 0:
         logger.error(f"GitHub CLI failed with exit code 
{p.returncode}.\nSTDOUT: {p.stdout.decode()}\nSTDERR:{p.stderr.decode()}")
@@ -251,62 +214,28 @@ if __name__ == "__main__":
     gh_json = json.loads(p.stdout)
     title = gh_json["title"]
     body = gh_json["body"]
-    reviews = gh_json["reviews"]
-
-    # Auto-fill reviewer from the current review event.
-    # Approvals are also review events, so approvers are automatically added.
-    reviewer_login = get_env("REVIEWER_LOGIN")
     pr_author = (gh_json.get("author") or {}).get("login")
-    if reviewer_login and reviewer_login != pr_author:
-        name, email = resolve_reviewer(reviewer_login)
-        if email:
-            identity = f"<{email}>"
-        else:
-            # Tier 4: fall back to the GitHub handle without tagging the 
reviewer.
-            identity = f"(github:{reviewer_login})"
-        resolved = f"{name} {identity}"
-        existing_reviewers = parse_trailers(title, body).get("Reviewers", [])
-        if not already_exists(identity, existing_reviewers):
-            existing_value = ", ".join(existing_reviewers)
-            new_value = f"{existing_value}, {resolved}" if existing_value else 
resolved
-            body = update_reviewers_trailer(body, f"Reviewers: {new_value}")
-
-    checks = [] # (bool (0=ok, 1=error), message)
-
-    def check(positive_assertion, ok_msg, err_msg):
-        if positive_assertion:
-            checks.append((0, f"{ok} {ok_msg}"))
-        else:
-            checks.append((1, f"{err} {err_msg}"))
-
-    # Check title
-    check(not title.endswith("..."), "Title is not truncated", "Title appears 
truncated (ends with ...)")
-    check(len(title) >= 15, "Title is not too short", "Title is too short 
(under 15 characters)")
-    check(len(title) <= 120, "Title is not too long", "Title is too long (over 
120 characters)")
-    ok_prefix = title.startswith("KAFKA-") or title.startswith("MINOR") or 
title.startswith("HOTFIX")
-    check(ok_prefix, "Title has expected KAFKA/MINOR/HOTFIX", "Title is 
missing KAFKA-XXXXX or MINOR/HOTFIX prefix")
-
-    # Check body
-    check(len(body) != 0, "Body is not empty", "Body is empty")
-    check("Delete this text and replace" not in body, "PR template text not 
present", "PR template text should be removed")
-    check("Committer Checklist" not in body, "PR template text not present", 
"Old PR template text should be removed")
-
-    paragraph_iter = split_paragraphs(body)
-    new_paragraphs = []
-    for p, markdown in paragraph_iter:
-        if markdown:
-            # If a paragraph looks like it has markdown in it, wrap each line 
separately.
-            new_lines = []
-            for line in p:
-                new_lines.append(textwrap.fill(line, width=72, 
break_long_words=False, break_on_hyphens=False, replace_whitespace=False))
-            rewrapped_p = "\n".join(new_lines)
-        else:
-            indent = ""
-            if len(p) > 0 and p[0].startswith("Reviewers:"):
-                indent = " "
-            rewrapped_p = textwrap.fill("".join(p), subsequent_indent=indent, 
width=72, break_long_words=False, break_on_hyphens=False, 
replace_whitespace=True)
-        new_paragraphs.append(rewrapped_p + "\n")
-    body = "\n".join(new_paragraphs)
+
+    if reviewer_login == pr_author:
+        logger.info(f"Reviewer {reviewer_login} is the PR author; not adding 
to Reviewers.")
+        exit(0)
+
+    name, email = resolve_reviewer(reviewer_login)
+    if email:
+        identity = f"<{email}>"
+    else:
+        # Fall back to the GitHub handle without tagging the reviewer.
+        identity = f"(github:{reviewer_login})"
+    resolved = f"{name} {identity}"
+
+    existing_reviewers = parse_trailers(title, body).get("Reviewers", [])
+    if already_exists(identity, existing_reviewers):
+        logger.info(f"Reviewer {resolved} already present; nothing to do.")
+        exit(0)
+
+    existing_value = ", ".join(existing_reviewers)
+    new_value = f"{existing_value}, {resolved}" if existing_value else resolved
+    body = update_reviewers_trailer(body, f"Reviewers: {new_value}")
 
     if get_env("GITHUB_ACTIONS"):
         with tempfile.NamedTemporaryFile() as fp:
@@ -317,39 +246,8 @@ if __name__ == "__main__":
             fp.close()
             if p.returncode != 0:
                 logger.error(f"Could not update PR {pr_number}. STDOUT: 
{p.stdout.decode()}")
+                exit(1)
+        logger.info(f"Added reviewer {resolved} to PR #{pr_number}.")
     else:
-        logger.info(f"Not reformatting {pr_number} since this is not running 
on GitHub Actions.")
-
-    # Check for Reviewers
-    approved = has_approval(reviews)
-    if approved:
-        trailers = parse_trailers(title, body)
-        reviewers_in_body = trailers.get("Reviewers", [])
-        check(len(reviewers_in_body) > 0, "Found 'Reviewers' in commit body", 
"Pull Request is approved, but no 'Reviewers' found in commit body")
-        if len(reviewers_in_body) > 0:
-            for reviewer_in_body in reviewers_in_body:
-                logger.debug(reviewer_in_body)
-
-    logger.debug("Commit will look like:\n")
-    logger.debug("<pre>")
-    io = BytesIO()
-    title += f" (#{pr_number})"
-    write_commit(io, title, body)
-    io.seek(0)
-    logger.debug(io.read().decode())
-    logger.debug("</pre>\n")
-
-    exit_code = 0
-    logger.debug("Validation results:")
-    for err, msg in checks:
-        logger.debug(f"* {msg}")
-
-    for err, msg in checks:
-        # Just output the first error for the status message. STDOUT becomes 
the status check message
-        if err:
-            print(msg)
-            exit(1)
-
-    logger.debug("No validation errors, PR format looks good!")
-    print("PR format looks good!")
+        logger.info(f"Not updating {pr_number} since this is not running on 
GitHub Actions.")
     exit(0)
diff --git a/.github/workflows/pr-linter.yml b/.github/workflows/pr-linter.yml
index 6b085cefad9..d38a9659a01 100644
--- a/.github/workflows/pr-linter.yml
+++ b/.github/workflows/pr-linter.yml
@@ -61,12 +61,7 @@ jobs:
           echo "Restored PR_NUMBER.txt:"
           cat PR_NUMBER.txt
           PR_NUMBER=$(cat PR_NUMBER.txt)
-          REVIEWER_LOGIN=""
-          if [ -f REVIEWER_LOGIN.txt ]; then
-            REVIEWER_LOGIN=$(cat REVIEWER_LOGIN.txt)
-            echo "Reviewer login: $REVIEWER_LOGIN"
-          fi
-          PR_NUMBER=$PR_NUMBER REVIEWER_LOGIN=$REVIEWER_LOGIN python 
.github/scripts/pr-format.py 2>> "$GITHUB_STEP_SUMMARY" 1>> pr-format-output.txt
+          PR_NUMBER=$PR_NUMBER python .github/scripts/pr-format.py 2>> 
"$GITHUB_STEP_SUMMARY" 1>> pr-format-output.txt
           exitcode="$?"
           message=$(cat pr-format-output.txt)
           echo "message=$message" >> "$GITHUB_OUTPUT"
diff --git a/.github/workflows/pr-reviewed.yml 
b/.github/workflows/pr-reviewed.yml
index 316c9c6b92c..7653541aa89 100644
--- a/.github/workflows/pr-reviewed.yml
+++ b/.github/workflows/pr-reviewed.yml
@@ -37,12 +37,8 @@ jobs:
           GITHUB_CONTEXT: ${{ toJson(github) }}
       - name: Save PR Number
         run: echo ${{ github.event.pull_request.number }} > PR_NUMBER.txt
-      - name: Save Reviewer Login
-        if: github.event_name == 'pull_request_review'
-        run: echo ${{ github.event.review.user.login }} > REVIEWER_LOGIN.txt
       - uses: actions/upload-artifact@v4
         with:
           name: PR_NUMBER.txt
           path: |
             PR_NUMBER.txt
-            REVIEWER_LOGIN.txt
diff --git a/.github/workflows/pr-reviewers-trailer-on-comment.yml 
b/.github/workflows/pr-reviewers-trailer-on-comment.yml
new file mode 100644
index 00000000000..92f555c4637
--- /dev/null
+++ b/.github/workflows/pr-reviewers-trailer-on-comment.yml
@@ -0,0 +1,57 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# Credits a reviewer in the PR's `Reviewers:` trailer when they leave a
+# top-level comment on the PR. This complements pr-reviewers-trailer-on-review
+# (which handles the Review/Approve path).
+#
+# `issue_comment` is not subject to the first-time-contributor approval gate
+# and always runs the workflow from the base repo's default branch, so fork
+# code never executes here.
+
+name: Reviewers Trailer (on comment)
+
+on:
+  issue_comment:
+    types: [created]
+
+permissions:
+  contents: read
+  pull-requests: write
+
+jobs:
+  append-trailer:
+    # Only run on PR comments (issue_comment also fires on plain issues).
+    # A bot or the PR author isn't a reviewer, so skip them.
+    if: |
+      github.event.issue.pull_request != null &&
+      github.event.comment.user.type != 'Bot' &&
+      github.event.comment.user.login != github.event.issue.user.login
+    runs-on: ubuntu-latest
+    steps:
+      - name: Env
+        run: printenv
+        env:
+          GITHUB_CONTEXT: ${{ toJson(github) }}
+      - name: Checkout code
+        uses: actions/checkout@v5
+        with:
+          persist-credentials: false
+      - name: Append reviewer trailer
+        env:
+          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+          PR_NUMBER: ${{ github.event.issue.number }}
+          REVIEWER_LOGIN: ${{ github.event.comment.user.login }}
+        run: python .github/scripts/pr-reviewers-trailer.py
diff --git a/.github/workflows/pr-reviewers-trailer-on-review.yml 
b/.github/workflows/pr-reviewers-trailer-on-review.yml
new file mode 100644
index 00000000000..c862c2117c8
--- /dev/null
+++ b/.github/workflows/pr-reviewers-trailer-on-review.yml
@@ -0,0 +1,77 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# When someone reviews a PR (even a plain Approve), credit them in the
+# `Reviewers:` trailer. Reviewer comes from workflow_run.actor.login.
+#
+# Two things to know:
+#   - We trigger on `types: [requested]`, not `completed`. On a first-time
+#     contributor's fork PR the "Pull Request Reviewed" run is blocked pending
+#     approval and never completes; the `requested` event still fires the
+#     moment it is queued, so we act on that.
+#   - workflow_run doesn't carry the PR number for fork PRs, so we recover it
+#     by matching the run's head_sha against the open PRs via the API.
+
+name: Reviewers Trailer (on review)
+
+on:
+  workflow_run:
+    workflows: [Pull Request Reviewed]
+    types:
+      - requested
+
+run-name: Reviewers Trailer for ${{ github.event.workflow_run.display_title }}
+
+jobs:
+  append-trailer:
+    # Only react to review submissions. The "Pull Request Reviewed" workflow
+    # also runs on `pull_request` (open / edit), where the actor is the PR
+    # author rather than a reviewer.
+    if: |
+      github.event_name == 'workflow_run' &&
+      github.event.workflow_run.event == 'pull_request_review'
+    runs-on: ubuntu-latest
+    permissions:
+      contents: read
+      pull-requests: write
+    steps:
+      - name: Env
+        run: printenv
+        env:
+          GITHUB_CONTEXT: ${{ toJson(github) }}
+      - name: Checkout code
+        uses: actions/checkout@v5
+        with:
+          persist-credentials: false
+      - name: Append reviewer trailer
+        env:
+          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+          REPO: ${{ github.repository }}
+          HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
+          REVIEWER_LOGIN: ${{ github.event.workflow_run.actor.login }}
+        run: |
+          set -e
+          # Match the run's head_sha against open PRs. Stream matches across
+          # all pages and take the first, rather than a per-page map(...).[0]
+          # which would emit a `null` per non-matching page under --paginate.
+          PR_NUMBER=$(gh api --paginate 
"repos/$REPO/pulls?state=open&per_page=100" \
+            --jq ".[] | select(.head.sha==\"$HEAD_SHA\") | .number" | head -n 
1)
+          if [ -z "$PR_NUMBER" ]; then
+            echo "No open PR found for head sha $HEAD_SHA; nothing to do."
+            exit 0
+          fi
+          echo "Crediting reviewer '$REVIEWER_LOGIN' on PR #$PR_NUMBER."
+          PR_NUMBER="$PR_NUMBER" REVIEWER_LOGIN="$REVIEWER_LOGIN" \
+            python .github/scripts/pr-reviewers-trailer.py

Reply via email to