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