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

SteNicholas pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/celeborn.git


The following commit(s) were added to refs/heads/main by this push:
     new 231ffee85f [CELEBORN-2440][INFRA] Post a merge summary comment on the 
PR in merge_pr.py
231ffee85f is described below

commit 231ffee85fb30c7c8b7db0dabf814b00a8cf6104
Author: Cheng Pan <[email protected]>
AuthorDate: Mon Sep 14 16:31:33 2026 +0800

    [CELEBORN-2440][INFRA] Post a merge summary comment on the PR in merge_pr.py
    
    ### What changes were proposed in this pull request?
    
    Port a few `merge_pr.py` improvements from Spark's `merge_spark_pr.py`:
    
    1. Post a "Merge Summary" comment on the PR after the merge, recording 
every branch the
       change landed on and a link to the resulting commit. `cherry_pick` now 
returns the
       `(ref, hash)` pair it pushed so backports are included, and the comment 
is posted from
       a `finally` block, so a cancelled cherry-pick does not drop what already 
landed. The
       backport path (PR already merged) posts a summary too.
    
    2. Close pull requests that GitHub does not auto-close. `Closes #N` closes 
the PR only
       when the commit lands on the default branch, so one merged into 
`branch-x.y` stays
       open. Merges into `main` are left to GitHub, whose close event carries 
the commit that
       the backport path below relies on.
    
    3. Recognize pull requests merged into `branch-x.y`. For the same reason, 
GitHub links the
       merge commit to the `closed` event only on the default branch, so those 
pull requests
       were not recognized as merged and re-running the script on one attempted 
a fresh merge
       instead of offering a backport. `find_merge_commit` now falls back to 
`referenced`
       events, confirmed against the `Closes #N from <ref>` merge footer.
    
    4. Polish the interactive prompts: capitalize `N` in the `(y/n)` prompts, 
since all of
       them treat any input other than `y` as no, and add the missing space 
after `":"` in
       the JIRA assignee prompt.
    
    The merge has already been pushed by the time the comment and the close 
run, so neither
    aborts the rest of the bookkeeping: failures are reported rather than 
raised, and the
    close is skipped when `GITHUB_OAUTH_KEY` is not configured.
    
    For example, #3788 landed on `main` and `branch-0.7`, and would have got:
    
    > **Merge Summary:**
    > - merged into main https://github.com/apache/celeborn/commit/8cbf3fa4
    > - merged into branch-0.7 
https://github.com/apache/celeborn/commit/f67883a4
    >
    > *Posted by `merge_pr.py`*
    
    ### Why are the changes needed?
    
    A merged PR currently records nothing about where the change landed: the 
commit is
    reachable only by searching for the `Closes #N` footer, and backport 
targets are not
    visible from the PR page at all. Pull requests merged into `branch-x.y` 
also stay open
    until someone closes them by hand, and the script does not recognize them 
as merged
    afterwards.
    
    ### Does this PR resolve a correctness bug?
    
    - [ ] Yes
    
    ### Does this PR introduce _any_ user-facing change?
    
    - [ ] Yes
    
    ### How was this patch tested?
    
    Manually: `python3 -m py_compile dev/merge_pr.py` and the module doctests, 
which now cover
    `has_merge_footer`, pass. `find_merge_commit` was checked against the real 
GitHub events of
    #3697 (merged into `branch-0.6`, previously undetected, now resolves 
`0bf9c5a58`), #3788 and
    #3768 (both merged into `main`, still resolved from the `closed` event). 
The GitHub write
    calls will be exercised by the next real merge.
    
    Closes #3823 from pan3793/CELEBORN-2440.
    
    Authored-by: Cheng Pan <[email protected]>
    Signed-off-by: Nicholas Jiang <[email protected]>
---
 dev/merge_pr.py | 188 +++++++++++++++++++++++++++++++++++++++++++++++++-------
 1 file changed, 166 insertions(+), 22 deletions(-)

diff --git a/dev/merge_pr.py b/dev/merge_pr.py
index adbad4f542..1953d69aa0 100755
--- a/dev/merge_pr.py
+++ b/dev/merge_pr.py
@@ -58,18 +58,28 @@ ASF_PASSWORD = os.environ.get("ASF_PASSWORD", "")
 # your own token management.
 JIRA_ACCESS_TOKEN = os.environ.get("JIRA_ACCESS_TOKEN")
 # OAuth key used for issuing requests against the GitHub API. If this is not 
defined, then requests
-# will be unauthenticated. You should only need to configure this if you find 
yourself regularly
-# exceeding your IP's unauthenticated request rate limit. You can create an 
OAuth key at
+# will be unauthenticated, so the merge summary comment is skipped and 
backport pull requests are
+# left open. You can create an OAuth key at
 # https://github.com/settings/tokens. This script only requires the 
"public_repo" scope.
 GITHUB_OAUTH_KEY = os.environ.get("GITHUB_OAUTH_KEY")
 
 
 GITHUB_BASE = "https://github.com/apache/celeborn/pull";
+GITHUB_COMMIT_BASE = "https://github.com/apache/celeborn/commit";
 GITHUB_API_BASE = "https://api.github.com/repos/apache/celeborn";
 JIRA_BASE = "https://issues.apache.org/jira/browse";
 JIRA_API_BASE = "https://issues.apache.org/jira";
 # Prefix added to temporary branches
 BRANCH_PREFIX = "PR_TOOL"
+# Branch that GitHub honors the "Closes #N" string on
+DEFAULT_BRANCH = "main"
+
+# The footer merge_pr generates: a "Closes #<pr> from <ref>" line alone on its 
paragraph,
+# followed by the authors paragraph. Requiring both rejects prose that merely 
mentions a PR.
+MERGE_FOOTER_RE = re.compile(
+    r"^Closes #(\d+) from \S+\s*$\n\n(?:Lead-authored-by|Authored-by):",
+    re.MULTILINE,
+)
 
 
 def get_json(url):
@@ -90,6 +100,125 @@ def get_json(url):
         sys.exit(-1)
 
 
+def close_pr(pr_num):
+    if not GITHUB_OAUTH_KEY:
+        print("GITHUB_OAUTH_KEY is not set; skipping closing PR #%s." % pr_num)
+        return None
+    url = "%s/pulls/%s" % (GITHUB_API_BASE, pr_num)
+    data = json.dumps({"state": "closed"}).encode("utf-8")
+    request = Request(url, data=data, method="PATCH")
+    request.add_header("Content-Type", "application/json")
+    request.add_header("Accept", "application/vnd.github+json")
+    if GITHUB_OAUTH_KEY:
+        request.add_header("Authorization", "token %s" % GITHUB_OAUTH_KEY)
+    try:
+        return json.load(urlopen(request))
+    except Exception as e:
+        print("Failed to close PR #%s: %s" % (pr_num, e))
+        return None
+
+
+def comment_pr(pr_num, body):
+    url = "%s/issues/%s/comments" % (GITHUB_API_BASE, pr_num)
+    data = json.dumps({"body": body}).encode("utf-8")
+    request = Request(url, data=data, method="POST")
+    request.add_header("Content-Type", "application/json")
+    request.add_header("Accept", "application/vnd.github+json")
+    if GITHUB_OAUTH_KEY:
+        request.add_header("Authorization", "token %s" % GITHUB_OAUTH_KEY)
+    try:
+        return json.load(urlopen(request))
+    except Exception as e:
+        print("Failed to comment on PR #%s: %s" % (pr_num, e))
+        return None
+
+
+def post_merge_comment(pr_num, merged_commits):
+    """Post a comment on the PR recording every branch the change landed on 
and a
+    link to the resulting commit, so the merge is traceable from the PR page.
+
+    ``merged_commits`` is an ordered list of (branch, commit hash) pairs, the 
merge
+    sink first followed by each cherry-pick target.
+    """
+    if not merged_commits:
+        return
+    lines = [
+        "- merged into %s %s/%s" % (ref, GITHUB_COMMIT_BASE, commit_hash)
+        for ref, commit_hash in merged_commits
+    ]
+    summary = "**Merge Summary:**\n" + "\n".join(lines)
+    attribution = "*Posted by `merge_pr.py`*"
+    body = "%s\n\n%s" % (summary, attribution)
+    print("\nPosting merge comment on PR #%s:\n\n%s\n%s" % (pr_num, summary, 
attribution))
+    if not GITHUB_OAUTH_KEY:
+        print("GITHUB_OAUTH_KEY is not set; skipping the merge comment.")
+        return
+    comment_pr(pr_num, body)
+
+
+def has_merge_footer(message, pr_num):
+    """Whether `message` carries the merge footer that `merge_pr` generates 
for `pr_num`.
+
+    Reads the last "Closes" paragraph, since a body quoting another PR's 
footer may hold an
+    earlier one. `pr_num` may be an int or a string of digits: callers read it 
from argv or
+    from the GitHub API, and comparing those two forms directly would never 
match.
+
+    >>> footer = "Closes #1 from a/b.\\n\\nAuthored-by: A <[email protected]>"
+    >>> has_merge_footer("[CELEBORN-1] Title\\n\\n" + footer, 1)
+    True
+    >>> has_merge_footer("[CELEBORN-1] Title\\n\\n" + footer, "1")
+    True
+    >>> has_merge_footer("[CELEBORN-1] Title\\n\\n" + footer, 2)
+    False
+    >>> has_merge_footer("[CELEBORN-1] Title\\n\\nSee #1 for details.", 1)
+    False
+
+    A cherry-pick keeps the footer, with `-x` provenance appended after it:
+
+    >>> pick = footer + "\\n(cherry picked from commit abc1234)"
+    >>> has_merge_footer("[CELEBORN-1] Title\\n\\n" + pick, 1)
+    True
+
+    A body quoting another PR's complete footer does not shadow the real one:
+
+    >>> quoted = "Reverting:\\n\\n" + footer + "\\n\\nSee above."
+    >>> own = footer.replace("#1", "#2")
+    >>> has_merge_footer("[CELEBORN-2] Later\\n\\n%s\\n\\n%s" % (quoted, own), 
1)
+    False
+    """
+    matches = MERGE_FOOTER_RE.findall(message)
+    return bool(matches) and matches[-1] == str(pr_num)
+
+
+def find_merge_commit(pr_num, pr_events):
+    """Return the (hash, message) of the commit that merged `pr_num`, or 
(None, None).
+
+    Merged pull requests don't appear as merged in the GitHub API; instead, 
they're closed
+    by committers. GitHub attributes a commit to the `closed` event only when 
that commit
+    lands on the default branch, because the "Closes #N" string in the commit 
message is
+    what closes the PR and it is honored only there. A pull request merged 
into branch-x.y
+    is closed without a commit, so fall back to `referenced` events, which are 
also raised
+    by any commit merely mentioning the PR; confirm each against the merge 
footer.
+    """
+
+    def commits_of(event_name):
+        matched = [e for e in pr_events if e["event"] == event_name and 
e["commit_id"] is not None]
+        return [e["commit_id"] for e in sorted(matched, key=lambda x: 
x["created_at"])]
+
+    def message_of(commit_hash):
+        return get_json("%s/commits/%s" % (GITHUB_API_BASE, 
commit_hash))["commit"]["message"]
+
+    closed_commits = commits_of("closed")
+    if closed_commits:
+        return closed_commits[-1], message_of(closed_commits[-1])
+
+    for commit_hash in reversed(commits_of("referenced")):
+        message = message_of(commit_hash)
+        if has_merge_footer(message, pr_num):
+            return commit_hash, message
+    return None, None
+
+
 def fail(msg):
     print(msg)
     clean_up()
@@ -105,7 +234,7 @@ def run_cmd(cmd):
 
 
 def continue_maybe(prompt):
-    result = input("\n%s (y/n): " % prompt)
+    result = input("\n%s (y/N): " % prompt)
     if result.lower() != "y":
         fail("Okay, exiting")
 
@@ -206,6 +335,8 @@ def merge_pr(pr_num, target_ref, title, body, pr_repo_desc):
     return merge_hash
 
 
+# cherry-pick the merge commit into the requested branch and return the
+# (pushed ref, pushed commit hash) pair
 def cherry_pick(pr_num, merge_hash, default_branch):
     pick_ref = input("Enter a branch name [%s]: " % default_branch)
     if pick_ref == "":
@@ -239,7 +370,7 @@ def cherry_pick(pr_num, merge_hash, default_branch):
 
     print("Pull request #%s picked into %s!" % (pr_num, pick_ref))
     print("Pick hash: %s" % pick_hash)
-    return pick_ref
+    return pick_ref, pick_hash
 
 
 def _semver_max_version(names):
@@ -261,7 +392,7 @@ def compute_default_fix_versions(merge_branches, 
unreleased_version_names):
     """
     default_fix_versions = []
     for b in merge_branches:
-        if b == "main":
+        if b == DEFAULT_BRANCH:
             chosen = _semver_max_version(
                 [n for n in unreleased_version_names if 
re.fullmatch(r"\d+\.0\.0", n)]
             )
@@ -390,7 +521,7 @@ def choose_jira_assignee(issue):
                     annotations.append("Commentor")
                 print("[%d] %s (%s)" % (idx, author.displayName, 
",".join(annotations)))
             raw_assignee = input(
-                "Enter number of user, or userid, to assign to (blank to leave 
unassigned):"
+                "Enter number of user, or userid, to assign to (blank to leave 
unassigned): "
             )
             if raw_assignee == "":
                 return None
@@ -563,7 +694,7 @@ def main():
         print("I've re-written the title as follows to match the standard 
format:")
         print("Original: %s" % pr["title"])
         print("Modified: %s" % modified_title)
-        result = input("Would you like to use the modified title? (y/n): ")
+        result = input("Would you like to use the modified title? (y/N): ")
         if result.lower() == "y":
             title = modified_title
             print("Using modified title:")
@@ -583,7 +714,7 @@ def main():
         print(modified_body)
         print("=" * 80)
         print("I've removed the comments from PR template like the above:")
-        result = input("Would you like to use the modified body? (y/n): ")
+        result = input("Would you like to use the modified body? (y/N): ")
         if result.lower() == "y":
             body = modified_body
             print("Using modified body:")
@@ -597,16 +728,9 @@ def main():
     base_ref = pr["head"]["ref"]
     pr_repo_desc = "%s/%s" % (user_login, base_ref)
 
-    # Merged pull requests don't appear as merged in the GitHub API;
-    # Instead, they're closed by committers.
-    merge_commits = [
-        e for e in pr_events if e["event"] == "closed" and e["commit_id"] is 
not None
-    ]
-
-    if merge_commits:
-        merge_hash = merge_commits[0]["commit_id"]
-        message = get_json("%s/commits/%s" % (GITHUB_API_BASE, 
merge_hash))["commit"]["message"]
+    merge_hash, message = find_merge_commit(pr_num, pr_events)
 
+    if merge_hash is not None:
         print("Pull request %s has already been merged, assuming you want to 
backport" % pr_num)
         commit_is_downloaded = (
             run_cmd(["git", "rev-parse", "--quiet", "--verify", "%s^{commit}" 
% merge_hash]).strip()
@@ -616,7 +740,8 @@ def main():
             fail("Couldn't find any merge commit for #%s, you may need to 
update HEAD." % pr_num)
 
         print("Found commit %s:\n%s" % (merge_hash, message))
-        cherry_pick(pr_num, merge_hash, next(branch_iter, branch_names[0]))
+        picked = cherry_pick(pr_num, merge_hash, next(branch_iter, 
branch_names[0]))
+        post_merge_comment(pr_num, [picked])
         sys.exit(0)
 
     if not bool(pr["mergeable"]):
@@ -634,11 +759,30 @@ def main():
 
     merge_hash = merge_pr(pr_num, target_ref, title, body, pr_repo_desc)
 
+    # Ordered (branch, commit hash) pairs for the merge comment: the merge 
sink first,
+    # then each cherry-pick target as it is picked.
+    merged_commits = [(target_ref, merge_hash)]
+
     pick_prompt = "Would you like to pick %s into another branch?" % merge_hash
-    while input("\n%s (y/n): " % pick_prompt).lower() == "y":
-        merged_refs = merged_refs + [
-            cherry_pick(pr_num, merge_hash, next(branch_iter, branch_names[0]))
-        ]
+    # Post the summary in a finally block: the merge into the target branch 
has already
+    # been pushed, so aborting a later cherry-pick must not drop that line.
+    try:
+        while input("\n%s (y/N): " % pick_prompt).lower() == "y":
+            picked = cherry_pick(pr_num, merge_hash, next(branch_iter, 
branch_names[0]))
+            merged_refs = merged_refs + [picked[0]]
+            merged_commits = merged_commits + [picked]
+    finally:
+        # Record what landed first: the merge has already been pushed, so 
nothing here
+        # may abort the remaining bookkeeping.
+        post_merge_comment(pr_num, merged_commits)
+        # The "Closes #N" string in the commit message auto-closes the PR only 
when the
+        # commit lands on the default branch, so close pull requests against 
other
+        # branches through the API. Merges into main are left to GitHub: 
closing them
+        # here would race its auto-close and replace the commit-linked close 
event that
+        # find_merge_commit prefers.
+        if target_ref != DEFAULT_BRANCH:
+            print("\nGitHub does not auto-close PRs targeting %s; closing 
it.\n" % target_ref)
+            close_pr(pr_num)
 
     if asf_jira is not None:
         continue_maybe("Would you like to update an associated JIRA?")

Reply via email to