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

jongyoul pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/zeppelin.git


The following commit(s) were added to refs/heads/master by this push:
     new 221c0c5c3d [ZEPPELIN-6404] Rewrite merge PR script in Python with no 
external dependencies
221c0c5c3d is described below

commit 221c0c5c3d5adb7056a212fc44316362a63ea140
Author: Jongyoul Lee <[email protected]>
AuthorDate: Mon Mar 30 13:37:10 2026 +0900

    [ZEPPELIN-6404] Rewrite merge PR script in Python with no external 
dependencies
    
    ## Summary
    
    - Rewrite `dev/merge_zeppelin_pr.py` as a single-file Python CLI 
(`dev/merge_pr.py`)
    - No external dependencies — uses only Python 3 built-in libraries 
(`urllib`, `json`, `subprocess`, `argparse`, `re`)
    - Runs directly with `python3 dev/merge_pr.py --pr <number> [flags]`
    - Non-interactive, AI-agent friendly design
    
    ## Motivation
    
    The existing Python merge script (`dev/merge_zeppelin_pr.py`) requires a 
Python virtual environment with dependencies (`jira`, `python-dotenv`), and is 
interactive (prompts for user input). This makes it difficult to use from CI/CD 
or AI agent workflows.
    
    The new version:
    - Requires only Python 3 (no venv, no pip install)
    - All flags are CLI arguments — no interactive prompts
    - Single file, ~450 lines, zero external dependencies
    - Instance-based design (`MergePR` class)
    
    ## Usage
    
    ```bash
    # Dry run (shows PR info without merging)
    python3 dev/merge_pr.py --pr 5167 --dry-run
    
    # Merge and resolve JIRA
    python3 dev/merge_pr.py --pr 5167 --resolve-jira --fix-versions 0.13.0
    
    # Merge and cherry-pick into release branches
    python3 dev/merge_pr.py --pr 5167 --release-branches branch-0.12,branch-0.11
    ```
    
    ## Flags
    
    | Flag | Description |
    |------|-------------|
    | `--pr` | Pull request number (required) |
    | `--target` | Target branch (default: PR base branch) |
    | `--fix-versions` | JIRA fix version(s), comma-separated |
    | `--release-branches` | Release branch(es) to cherry-pick into, 
comma-separated |
    | `--resolve-jira` | Resolve associated JIRA issue(s) |
    | `--dry-run` | Show what would be done without making changes |
    | `--push-remote` | Git remote for pushing (default: `apache`) |
    
    Tokens are read from environment variables: `GITHUB_OAUTH_KEY`, 
`JIRA_ACCESS_TOKEN`.
    
    ## Test Plan
    
    - [x] `python3 dev/merge_pr.py --help` — shows usage
    - [x] `python3 dev/merge_pr.py --pr 5167 --dry-run` — fetches PR info 
correctly
    - [ ] Full merge test with a real PR
    - [ ] Cherry-pick into release branch test
    - [ ] JIRA resolution test
    
    Closes #5183 from jongyoul/ZEPPELIN-6404-go-merge-cli-v2.
    
    Signed-off-by: Jongyoul Lee <[email protected]>
---
 dev/merge_pr.py | 449 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 449 insertions(+)

diff --git a/dev/merge_pr.py b/dev/merge_pr.py
new file mode 100644
index 0000000000..ef87dad4d1
--- /dev/null
+++ b/dev/merge_pr.py
@@ -0,0 +1,449 @@
+#!/usr/bin/env python3
+#
+# 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.
+#
+
+"""merge_pr.py - Merge Apache Zeppelin pull requests via the GitHub API.
+
+Optionally cherry-picks into release branches and resolves JIRA issues.
+No external dependencies — uses only Python 3 built-in libraries.
+
+Usage:
+    python3 dev/merge_pr.py --pr 5167 --dry-run
+    python3 dev/merge_pr.py --pr 5167 --resolve-jira --fix-versions 0.13.0
+    python3 dev/merge_pr.py --pr 5167 --resolve-jira --release-branches 
branch-0.12
+"""
+
+import argparse
+import json
+import os
+import re
+import subprocess
+import sys
+import urllib.error
+import urllib.request
+
+GITHUB_API_BASE = "https://api.github.com/repos/apache/zeppelin";
+JIRA_API_BASE = "https://issues.apache.org/jira/rest/api/2";
+
+DEFAULT_BRANCH = "master"
+DEFAULT_REMOTE = "apache"
+JIRA_RESOLVE_TRANSITION = "Resolve Issue"
+JIRA_CLOSED_STATUSES = frozenset(("Resolved", "Closed"))
+
+JIRA_ID_RE = re.compile(r"ZEPPELIN-\d{3,6}")
+TITLE_FORMATTED_RE = re.compile(r"^\[ZEPPELIN-\d{3,6}](\[[A-Z0-9_\s,]+] )+\S+")
+TITLE_REF_RE = re.compile(r"(?i)(ZEPPELIN[-\s]*\d{3,6})")
+COMPONENT_RE = re.compile(r"(?i)(\[[\w\s,.\-]+])")
+WHITESPACE_RE = re.compile(r"\s+")
+LEADING_NON_WORD_RE = re.compile(r"^\W+")
+SEMANTIC_VER_RE = re.compile(r"^\d+\.\d+\.\d+$")
+
+
+class MergePR:
+    def __init__(self, args):
+        self.pr = args.pr
+        self.target = args.target or ""
+        self.fix_versions = _parse_csv(args.fix_versions) if args.fix_versions 
else []
+        self.release_branches = _parse_csv(args.release_branches) if 
args.release_branches else []
+        self.resolve_jira = args.resolve_jira
+        self.dry_run = args.dry_run
+        self.push_remote = args.push_remote or 
os.environ.get("PUSH_REMOTE_NAME", DEFAULT_REMOTE)
+        self.github_token = args.github_token or 
os.environ.get("GITHUB_OAUTH_KEY", "")
+        self.jira_token = args.jira_token or 
os.environ.get("JIRA_ACCESS_TOKEN", "")
+
+    # ── Git ──────────────────────────────────────────────────────────────
+
+    def _git(self, *args):
+        result = subprocess.run(
+            ["git", *args],
+            capture_output=True, text=True,
+        )
+        if result.returncode != 0:
+            output = (result.stdout + result.stderr).strip()
+            raise RuntimeError(f"git {' '.join(args)} failed:\n{output}")
+        return result.stdout.strip()
+
+    def _git_current_ref(self):
+        ref = self._git("rev-parse", "--abbrev-ref", "HEAD")
+        return self._git("rev-parse", "HEAD") if ref == "HEAD" else ref
+
+    # ── HTTP ─────────────────────────────────────────────────────────────
+
+    def _http(self, method, url, payload=None, auth=""):
+        data = json.dumps(payload).encode() if payload is not None else None
+        req = urllib.request.Request(url, data=data, method=method)
+        req.add_header("Content-Type", "application/json")
+        req.add_header("Accept", "application/json")
+        if auth:
+            req.add_header("Authorization", auth)
+        try:
+            with urllib.request.urlopen(req) as resp:
+                return resp.status, json.loads(resp.read().decode())
+        except urllib.error.HTTPError as e:
+            err_body = e.read().decode() if e.fp else ""
+            try:
+                return e.code, json.loads(err_body)
+            except json.JSONDecodeError:
+                return e.code, {"error": err_body}
+
+    # ── GitHub ───────────────────────────────────────────────────────────
+
+    def _gh_auth(self):
+        return f"token {self.github_token}" if self.github_token else ""
+
+    def _gh_get_pr(self, num):
+        code, data = self._http("GET", f"{GITHUB_API_BASE}/pulls/{num}", 
auth=self._gh_auth())
+        if code != 200:
+            raise RuntimeError(f"GET PR #{num}: HTTP {code}")
+        return data
+
+    def _gh_merge_pr(self, num, title, msg):
+        payload = {"commit_title": title, "commit_message": msg, 
"merge_method": "squash"}
+        code, data = self._http("PUT", f"{GITHUB_API_BASE}/pulls/{num}/merge", 
payload, self._gh_auth())
+        if code == 405:
+            raise RuntimeError(f"Merge PR #{num} is not allowed")
+        if code != 200:
+            raise RuntimeError(f"Merge PR #{num}: HTTP {code}")
+        return data
+
+    def _gh_comment_pr(self, num, comment):
+        code, _ = self._http("POST", 
f"{GITHUB_API_BASE}/issues/{num}/comments",
+                             {"body": comment}, self._gh_auth())
+        if code != 201:
+            print(f"Warning: comment PR #{num}: HTTP {code}", file=sys.stderr)
+
+    # ── JIRA ─────────────────────────────────────────────────────────────
+
+    def _jira_auth(self):
+        return f"Bearer {self.jira_token}" if self.jira_token else ""
+
+    def _jira_get_issue(self, key):
+        code, data = self._http("GET", f"{JIRA_API_BASE}/issue/{key}", 
auth=self._jira_auth())
+        if code != 200:
+            raise RuntimeError(f"GET {key}: HTTP {code}")
+        return data
+
+    def _jira_unreleased_versions(self):
+        code, data = self._http("GET", 
f"{JIRA_API_BASE}/project/ZEPPELIN/versions", auth=self._jira_auth())
+        if code != 200:
+            raise RuntimeError(f"GET versions: HTTP {code}")
+        versions = []
+        for v in data:
+            name = v.get("name", "")
+            if not v.get("released") and not v.get("archived") and 
SEMANTIC_VER_RE.match(name):
+                versions.append({"id": str(v["id"]), "name": name})
+        versions.sort(key=lambda v: _ver_tuple(v["name"]), reverse=True)
+        return versions
+
+    def _jira_transitions(self, key):
+        code, data = self._http("GET", 
f"{JIRA_API_BASE}/issue/{key}/transitions", auth=self._jira_auth())
+        if code != 200:
+            raise RuntimeError(f"GET transitions {key}: HTTP {code}")
+        return [{"id": t["id"], "name": t["name"]} for t in 
data.get("transitions", [])]
+
+    def _jira_resolve(self, key, transition_id, fix_ver, comment):
+        payload = {
+            "transition": {"id": transition_id},
+            "update": {
+                "comment": [{"add": {"body": comment}}],
+                "fixVersions": [{"add": {"id": fv["id"], "name": fv["name"]}} 
for fv in fix_ver],
+            },
+        }
+        code, _ = self._http("POST", 
f"{JIRA_API_BASE}/issue/{key}/transitions", payload, self._jira_auth())
+        if code != 204:
+            raise RuntimeError(f"Resolve {key}: HTTP {code}")
+
+    # ── Fix version resolution ───────────────────────────────────────────
+
+    def _resolve_fix_versions(self, branches, versions):
+        """Resolve fix version objects from explicit --fix-versions and branch 
inference.
+
+        Returns a list of version dicts ({"id": ..., "name": ...}).
+        Raises RuntimeError if an explicit fix version is not found.
+        """
+        vm = {v["name"]: v for v in versions}
+        fix_ver, seen = [], set()
+
+        for fv in self.fix_versions:
+            if fv not in vm:
+                raise RuntimeError(f'fix version "{fv}" not found')
+            fix_ver.append(vm[fv])
+            seen.add(fv)
+
+        infer_master = not self.fix_versions
+        latest = versions[0]["name"]
+        names = []
+        for branch in branches:
+            if branch == DEFAULT_BRANCH:
+                if infer_master and latest not in seen:
+                    names.append(latest)
+                    seen.add(latest)
+            else:
+                prefix = branch[len("branch-"):] if 
branch.startswith("branch-") else branch
+                found = [v["name"] for v in versions if 
v["name"].startswith(prefix + ".") or v["name"] == prefix]
+                if found:
+                    pick = found[-1]  # smallest matching (list is desc-sorted)
+                    if pick not in seen:
+                        names.append(pick)
+                        seen.add(pick)
+                else:
+                    print(f"Warning: no version found for {branch}, skipping", 
file=sys.stderr)
+
+        # Remove redundant X.Y.0 when X.(Y-1).0 is also present
+        filtered = []
+        for v in names:
+            parts = v.split(".")
+            if len(parts) == 3 and parts[2] == "0":
+                minor = int(parts[1])
+                if minor > 0 and f"{parts[0]}.{minor - 1}.0" in seen:
+                    continue
+            filtered.append(v)
+
+        inferred = [vm[n] for n in filtered if n in vm]
+        if inferred:
+            print(f"Auto-inferred fix version(s): {', '.join(filtered)}")
+        fix_ver.extend(inferred)
+        return fix_ver
+
+    # ── Effective command ────────────────────────────────────────────────
+
+    def _print_effective_command(self, target_branch, fix_ver):
+        parts = ["python3 dev/merge_pr.py", f"--pr {self.pr}"]
+        if target_branch and target_branch != DEFAULT_BRANCH:
+            parts.append(f"--target {target_branch}")
+        if self.release_branches:
+            parts.append(f"--release-branches 
{','.join(self.release_branches)}")
+        if self.resolve_jira:
+            parts.append("--resolve-jira")
+        if fix_ver:
+            parts.append(f"--fix-versions {','.join(fv['name'] for fv in 
fix_ver)}")
+        if self.push_remote != DEFAULT_REMOTE:
+            parts.append(f"--push-remote {self.push_remote}")
+        print(f"[dry-run] Effective command:\n  {' '.join(parts)}")
+
+    # ── Main flow ────────────────────────────────────────────────────────
+
+    def run(self):
+        original_head = self._git_current_ref()
+
+        pr_data = self._gh_get_pr(self.pr)
+        if not pr_data.get("mergeable"):
+            raise RuntimeError(f"PR #{self.pr} is not mergeable")
+        pr_title = pr_data["title"]
+        if "[WIP]" in pr_title:
+            print(f"WARNING: PR title contains [WIP]: {pr_title}", 
file=sys.stderr)
+
+        target_branch = self.target or pr_data["base"]["ref"]
+        title = _standardize_title(pr_title)
+        src = f"{pr_data['user']['login']}/{pr_data['head']['ref']}"
+        pr_body = pr_data.get("body", "") or ""
+
+        print(f"=== Pull Request #{self.pr} ===")
+        print(f"title:  {title}")
+        print(f"source: {src}")
+        print(f"target: {target_branch}")
+        print(f"url:    {pr_data['url']}")
+        if self.release_branches:
+            print(f"release-branches: {', '.join(self.release_branches)}")
+
+        # Resolve fix versions once (used for both dry-run display and actual 
JIRA resolution)
+        fix_ver = []
+        if self.resolve_jira and self.jira_token and JIRA_ID_RE.search(title):
+            try:
+                versions = self._jira_unreleased_versions()
+                if versions:
+                    branches = [target_branch] + self.release_branches
+                    fix_ver = self._resolve_fix_versions(branches, versions)
+            except RuntimeError as e:
+                print(f"Warning: failed to resolve fix versions: {e}", 
file=sys.stderr)
+
+        if self.dry_run:
+            print()
+            self._print_effective_command(target_branch, fix_ver)
+            return
+
+        # Merge
+        body = pr_body.replace("@", "<at>")
+        try:
+            name = self._git("config", "--get", "user.name")
+        except RuntimeError:
+            name = ""
+        try:
+            email = self._git("config", "--get", "user.email")
+        except RuntimeError:
+            email = ""
+        msg = f"{body}\n\nCloses #{self.pr} from {src}.\n\nSigned-off-by: 
{name} <{email}>"
+
+        merge_data = self._gh_merge_pr(self.pr, title, msg)
+        sha = merge_data["sha"]
+        print(f"\nPR #{self.pr} merged! (hash: {_short_sha(sha)})")
+
+        try:
+            self._git("fetch", self.push_remote, target_branch)
+        except RuntimeError:
+            pass
+
+        # Cherry-pick into release branches
+        merged = [target_branch]
+        for branch in self.release_branches:
+            pick = _pick_branch_name(self.pr, branch)
+            try:
+                self._git("fetch", self.push_remote, f"{branch}:{pick}")
+            except RuntimeError as e:
+                print(f"Warning: fetch {branch} failed: {e}", file=sys.stderr)
+                continue
+            self._git("checkout", pick)
+            try:
+                self._git("cherry-pick", "-sx", sha)
+                self._git("push", self.push_remote, f"{pick}:{branch}")
+                h = self._git("rev-parse", pick)
+                print(f"Picked into {branch} (hash: {_short_sha(h)})")
+                merged.append(branch)
+            except RuntimeError as e:
+                print(f"Warning: cherry-pick/push into {branch} failed: {e}", 
file=sys.stderr)
+                try:
+                    self._git("cherry-pick", "--abort")
+                except RuntimeError:
+                    pass
+            finally:
+                self._git("checkout", original_head)
+                self._git("branch", "-D", pick)
+
+        self._comment_merge_summary(merged, sha)
+
+        if self.resolve_jira:
+            try:
+                self._do_resolve_jira(title, fix_ver)
+            except RuntimeError as e:
+                print(f"Warning: JIRA resolution failed: {e}", file=sys.stderr)
+
+    def _comment_merge_summary(self, merged, sha):
+        lines = [f"Merged into {merged[0]} ({_short_sha(sha)})."]
+        for branch in merged[1:]:
+            lines.append(f"Cherry-picked into {branch}.")
+        try:
+            self._gh_comment_pr(self.pr, "\n".join(lines))
+            print("Commented on PR with merge summary.")
+        except RuntimeError as e:
+            print(f"Warning: failed to comment on PR: {e}", file=sys.stderr)
+
+    def _do_resolve_jira(self, title, fix_ver):
+        if not self.jira_token:
+            raise RuntimeError("JIRA_ACCESS_TOKEN is not set")
+
+        ids = JIRA_ID_RE.findall(title)
+        if not ids:
+            print("No JIRA ID found in PR title, skipping.")
+            return
+
+        for jira_id in ids:
+            try:
+                issue = self._jira_get_issue(jira_id)
+            except RuntimeError as e:
+                print(f"Warning: get {jira_id}: {e}", file=sys.stderr)
+                continue
+            status = issue.get("fields", {}).get("status", {}).get("name", "")
+            if status in JIRA_CLOSED_STATUSES:
+                print(f'JIRA {jira_id} already "{status}", skipping.')
+                continue
+
+            print(f"=== JIRA {jira_id} ===")
+            print(f"Summary:  {issue.get('fields', {}).get('summary', '')}")
+            print(f"Status:   {status}")
+
+            transitions = self._jira_transitions(jira_id)
+            resolve_id = next((t["id"] for t in transitions if t["name"] == 
JIRA_RESOLVE_TRANSITION), None)
+            if not resolve_id:
+                print(f"Warning: no '{JIRA_RESOLVE_TRANSITION}' transition for 
{jira_id}", file=sys.stderr)
+                continue
+
+            jira_comment = (
+                f"Issue resolved by pull request {self.pr}"
+                f"\n[https://github.com/apache/zeppelin/pull/{self.pr}]";
+            )
+            try:
+                self._jira_resolve(jira_id, resolve_id, fix_ver, jira_comment)
+                print(f"Resolved {jira_id}!")
+            except RuntimeError as e:
+                print(f"Warning: resolve {jira_id}: {e}", file=sys.stderr)
+
+
+# ── Module-level utilities ───────────────────────────────────────────────
+
+def _parse_csv(value):
+    return [s.strip() for s in value.split(",") if s.strip()] if value else []
+
+
+def _ver_tuple(v):
+    return tuple(int(x) for x in v.split("."))
+
+
+def _short_sha(sha):
+    return sha[:8] if len(sha) > 8 else sha
+
+
+def _pick_branch_name(pr_num, branch):
+    return f"PR_TOOL_PICK_PR_{pr_num}_{branch.upper()}"
+
+
+def _standardize_title(text):
+    text = text.rstrip(".")
+    if text.startswith('Revert "') and text.endswith('"'):
+        return text
+    if TITLE_FORMATTED_RE.match(text):
+        return text
+
+    jira_refs = []
+    for m in TITLE_REF_RE.finditer(text):
+        ref = m.group(1)
+        jira_refs.append("[" + WHITESPACE_RE.sub("-", ref.upper()) + "]")
+        text = text.replace(ref, "")
+
+    components = []
+    for m in COMPONENT_RE.finditer(text):
+        comp = m.group(1)
+        components.append(comp.upper())
+        text = text.replace(comp, "")
+
+    text = LEADING_NON_WORD_RE.sub("", text)
+    result = "".join(jira_refs) + "".join(components) + " " + text
+    return WHITESPACE_RE.sub(" ", result.strip())
+
+
+# ── Entry point ──────────────────────────────────────────────────────────
+
+def main():
+    parser = argparse.ArgumentParser(
+        description="Merge Apache Zeppelin pull requests",
+        usage="python3 dev/merge_pr.py [flags]",
+    )
+    parser.add_argument("--pr", type=int, required=True, help="Pull request 
number")
+    parser.add_argument("--target", default="", help="Target branch (default: 
PR base branch)")
+    parser.add_argument("--fix-versions", default="", help="JIRA fix 
version(s), comma-separated")
+    parser.add_argument("--release-branches", default="", help="Release 
branch(es) to cherry-pick into, comma-separated")
+    parser.add_argument("--resolve-jira", action="store_true", help="Resolve 
associated JIRA issue(s)")
+    parser.add_argument("--dry-run", action="store_true", help="Show what 
would be done without making changes")
+    parser.add_argument("--push-remote", default="", help="Git remote for 
pushing (default: apache)")
+    parser.add_argument("--github-token", default="", help="GitHub OAuth token 
(env: GITHUB_OAUTH_KEY)")
+    parser.add_argument("--jira-token", default="", help="JIRA access token 
(env: JIRA_ACCESS_TOKEN)")
+
+    args = parser.parse_args()
+    MergePR(args).run()
+
+
+if __name__ == "__main__":
+    main()

Reply via email to