Skip to content

Commit 85f63fe

Browse files
jongyoulclaude
andcommitted
[ZEPPELIN-6404] Simplify merge_pr.py: deduplicate JIRA calls, extract constants
- Resolve fix versions once and pass to both dry-run and JIRA resolution - Unify _resolve_fix_version_names and _do_resolve_jira fix-version logic into single _resolve_fix_versions method - Extract constants: DEFAULT_BRANCH, DEFAULT_REMOTE, JIRA_RESOLVE_TRANSITION, JIRA_CLOSED_STATUSES - Use try/finally in cherry-pick loop to guarantee HEAD restoration - Extract _pick_branch_name helper - Rename _http param body→payload to avoid shadowing Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent e2c5da9 commit 85f63fe

1 file changed

Lines changed: 76 additions & 89 deletions

File tree

dev/merge_pr.py

Lines changed: 76 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,11 @@
3939
GITHUB_API_BASE = "https://api.github.com/repos/apache/zeppelin"
4040
JIRA_API_BASE = "https://issues.apache.org/jira/rest/api/2"
4141

42+
DEFAULT_BRANCH = "master"
43+
DEFAULT_REMOTE = "apache"
44+
JIRA_RESOLVE_TRANSITION = "Resolve Issue"
45+
JIRA_CLOSED_STATUSES = frozenset(("Resolved", "Closed"))
46+
4247
JIRA_ID_RE = re.compile(r"ZEPPELIN-\d{3,6}")
4348
TITLE_FORMATTED_RE = re.compile(r"^\[ZEPPELIN-\d{3,6}](\[[A-Z0-9_\s,]+] )+\S+")
4449
TITLE_REF_RE = re.compile(r"(?i)(ZEPPELIN[-\s]*\d{3,6})")
@@ -56,15 +61,15 @@ def __init__(self, args):
5661
self.release_branches = _parse_csv(args.release_branches) if args.release_branches else []
5762
self.resolve_jira = args.resolve_jira
5863
self.dry_run = args.dry_run
59-
self.push_remote = args.push_remote or os.environ.get("PUSH_REMOTE_NAME", "apache")
64+
self.push_remote = args.push_remote or os.environ.get("PUSH_REMOTE_NAME", DEFAULT_REMOTE)
6065
self.github_token = args.github_token or os.environ.get("GITHUB_OAUTH_KEY", "")
6166
self.jira_token = args.jira_token or os.environ.get("JIRA_ACCESS_TOKEN", "")
6267

6368
# ── Git ──────────────────────────────────────────────────────────────
6469

6570
def _git(self, *args):
6671
result = subprocess.run(
67-
["git"] + list(args),
72+
["git", *args],
6873
capture_output=True, text=True,
6974
)
7075
if result.returncode != 0:
@@ -78,8 +83,8 @@ def _git_current_ref(self):
7883

7984
# ── HTTP ─────────────────────────────────────────────────────────────
8085

81-
def _http(self, method, url, body=None, auth=""):
82-
data = json.dumps(body).encode() if body is not None else None
86+
def _http(self, method, url, payload=None, auth=""):
87+
data = json.dumps(payload).encode() if payload is not None else None
8388
req = urllib.request.Request(url, data=data, method=method)
8489
req.add_header("Content-Type", "application/json")
8590
req.add_header("Accept", "application/json")
@@ -89,11 +94,11 @@ def _http(self, method, url, body=None, auth=""):
8994
with urllib.request.urlopen(req) as resp:
9095
return resp.status, json.loads(resp.read().decode())
9196
except urllib.error.HTTPError as e:
92-
body_text = e.read().decode() if e.fp else ""
97+
err_body = e.read().decode() if e.fp else ""
9398
try:
94-
return e.code, json.loads(body_text)
99+
return e.code, json.loads(err_body)
95100
except json.JSONDecodeError:
96-
return e.code, {"error": body_text}
101+
return e.code, {"error": err_body}
97102

98103
# ── GitHub ───────────────────────────────────────────────────────────
99104

@@ -107,8 +112,8 @@ def _gh_get_pr(self, num):
107112
return data
108113

109114
def _gh_merge_pr(self, num, title, msg):
110-
body = {"commit_title": title, "commit_message": msg, "merge_method": "squash"}
111-
code, data = self._http("PUT", f"{GITHUB_API_BASE}/pulls/{num}/merge", body, self._gh_auth())
115+
payload = {"commit_title": title, "commit_message": msg, "merge_method": "squash"}
116+
code, data = self._http("PUT", f"{GITHUB_API_BASE}/pulls/{num}/merge", payload, self._gh_auth())
112117
if code == 405:
113118
raise RuntimeError(f"Merge PR #{num} is not allowed")
114119
if code != 200:
@@ -151,26 +156,42 @@ def _jira_transitions(self, key):
151156
return [{"id": t["id"], "name": t["name"]} for t in data.get("transitions", [])]
152157

153158
def _jira_resolve(self, key, transition_id, fix_ver, comment):
154-
body = {
159+
payload = {
155160
"transition": {"id": transition_id},
156161
"update": {
157162
"comment": [{"add": {"body": comment}}],
158163
"fixVersions": [{"add": {"id": fv["id"], "name": fv["name"]}} for fv in fix_ver],
159164
},
160165
}
161-
code, _ = self._http("POST", f"{JIRA_API_BASE}/issue/{key}/transitions", body, self._jira_auth())
166+
code, _ = self._http("POST", f"{JIRA_API_BASE}/issue/{key}/transitions", payload, self._jira_auth())
162167
if code != 204:
163168
raise RuntimeError(f"Resolve {key}: HTTP {code}")
164169

165-
# ── Fix version inference ────────────────────────────────────────────
170+
# ── Fix version resolution ───────────────────────────────────────────
171+
172+
def _resolve_fix_versions(self, branches, versions):
173+
"""Resolve fix version objects from explicit --fix-versions and branch inference.
174+
175+
Returns a list of version dicts ({"id": ..., "name": ...}).
176+
Raises RuntimeError if an explicit fix version is not found.
177+
"""
178+
vm = {v["name"]: v for v in versions}
179+
fix_ver, seen = [], set()
180+
181+
for fv in self.fix_versions:
182+
if fv not in vm:
183+
raise RuntimeError(f'fix version "{fv}" not found')
184+
fix_ver.append(vm[fv])
185+
seen.add(fv)
166186

167-
def _infer_fix_versions(self, merged, versions, infer_master):
168-
names, seen = [], set()
169-
for branch in merged:
170-
if branch == "master":
171-
if infer_master and versions[0]["name"] not in seen:
172-
names.append(versions[0]["name"])
173-
seen.add(versions[0]["name"])
187+
infer_master = not self.fix_versions
188+
latest = versions[0]["name"]
189+
names = []
190+
for branch in branches:
191+
if branch == DEFAULT_BRANCH:
192+
if infer_master and latest not in seen:
193+
names.append(latest)
194+
seen.add(latest)
174195
else:
175196
prefix = branch[len("branch-"):] if branch.startswith("branch-") else branch
176197
found = [v["name"] for v in versions if v["name"].startswith(prefix + ".") or v["name"] == prefix]
@@ -192,25 +213,25 @@ def _infer_fix_versions(self, merged, versions, infer_master):
192213
continue
193214
filtered.append(v)
194215

195-
vm = {v["name"]: v for v in versions}
196-
result = [vm[n] for n in filtered if n in vm]
197-
if result:
216+
inferred = [vm[n] for n in filtered if n in vm]
217+
if inferred:
198218
print(f"Auto-inferred fix version(s): {', '.join(filtered)}")
199-
return result
219+
fix_ver.extend(inferred)
220+
return fix_ver
200221

201222
# ── Effective command ────────────────────────────────────────────────
202223

203-
def _print_effective_command(self, target_branch, resolved_versions):
224+
def _print_effective_command(self, target_branch, fix_ver):
204225
parts = ["python3 dev/merge_pr.py", f"--pr {self.pr}"]
205-
if target_branch and target_branch != "master":
226+
if target_branch and target_branch != DEFAULT_BRANCH:
206227
parts.append(f"--target {target_branch}")
207228
if self.release_branches:
208229
parts.append(f"--release-branches {','.join(self.release_branches)}")
209230
if self.resolve_jira:
210231
parts.append("--resolve-jira")
211-
if resolved_versions:
212-
parts.append(f"--fix-versions {','.join(resolved_versions)}")
213-
if self.push_remote != "apache":
232+
if fix_ver:
233+
parts.append(f"--fix-versions {','.join(fv['name'] for fv in fix_ver)}")
234+
if self.push_remote != DEFAULT_REMOTE:
214235
parts.append(f"--push-remote {self.push_remote}")
215236
print(f"[dry-run] Effective command:\n {' '.join(parts)}")
216237

@@ -239,11 +260,20 @@ def run(self):
239260
if self.release_branches:
240261
print(f"release-branches: {', '.join(self.release_branches)}")
241262

242-
resolved_fix_versions = self._resolve_fix_version_names(title, target_branch)
263+
# Resolve fix versions once (used for both dry-run display and actual JIRA resolution)
264+
fix_ver = []
265+
if self.resolve_jira and self.jira_token and JIRA_ID_RE.search(title):
266+
try:
267+
versions = self._jira_unreleased_versions()
268+
if versions:
269+
branches = [target_branch] + self.release_branches
270+
fix_ver = self._resolve_fix_versions(branches, versions)
271+
except RuntimeError as e:
272+
print(f"Warning: failed to resolve fix versions: {e}", file=sys.stderr)
243273

244274
if self.dry_run:
245275
print()
246-
self._print_effective_command(target_branch, resolved_fix_versions)
276+
self._print_effective_command(target_branch, fix_ver)
247277
return
248278

249279
# Merge
@@ -270,7 +300,7 @@ def run(self):
270300
# Cherry-pick into release branches
271301
merged = [target_branch]
272302
for branch in self.release_branches:
273-
pick = f"PR_TOOL_PICK_PR_{self.pr}_{branch.upper()}"
303+
pick = _pick_branch_name(self.pr, branch)
274304
try:
275305
self._git("fetch", self.push_remote, f"{branch}:{pick}")
276306
except RuntimeError as e:
@@ -279,59 +309,28 @@ def run(self):
279309
self._git("checkout", pick)
280310
try:
281311
self._git("cherry-pick", "-sx", sha)
312+
self._git("push", self.push_remote, f"{pick}:{branch}")
313+
h = self._git("rev-parse", pick)
314+
print(f"Picked into {branch} (hash: {_short_sha(h)})")
315+
merged.append(branch)
282316
except RuntimeError as e:
283-
print(f"Warning: cherry-pick into {branch} failed: {e}", file=sys.stderr)
317+
print(f"Warning: cherry-pick/push into {branch} failed: {e}", file=sys.stderr)
284318
try:
285319
self._git("cherry-pick", "--abort")
286320
except RuntimeError:
287321
pass
322+
finally:
288323
self._git("checkout", original_head)
289324
self._git("branch", "-D", pick)
290-
continue
291-
try:
292-
self._git("push", self.push_remote, f"{pick}:{branch}")
293-
h = self._git("rev-parse", pick)
294-
print(f"Picked into {branch} (hash: {_short_sha(h)})")
295-
merged.append(branch)
296-
except RuntimeError as e:
297-
print(f"Warning: push to {branch} failed: {e}", file=sys.stderr)
298-
self._git("checkout", original_head)
299-
self._git("branch", "-D", pick)
300325

301326
self._comment_merge_summary(merged, sha)
302327

303328
if self.resolve_jira:
304329
try:
305-
self._do_resolve_jira(title, merged)
330+
self._do_resolve_jira(title, fix_ver)
306331
except RuntimeError as e:
307332
print(f"Warning: JIRA resolution failed: {e}", file=sys.stderr)
308333

309-
def _resolve_fix_version_names(self, title, target_branch):
310-
if not self.resolve_jira or not self.jira_token:
311-
return list(self.fix_versions)
312-
313-
ids = JIRA_ID_RE.findall(title)
314-
if not ids:
315-
return list(self.fix_versions)
316-
317-
try:
318-
versions = self._jira_unreleased_versions()
319-
if not versions:
320-
return list(self.fix_versions)
321-
322-
vm = {v["name"]: v for v in versions}
323-
resolved = list(dict.fromkeys(fv for fv in self.fix_versions if fv in vm))
324-
325-
infer_master = not self.fix_versions
326-
branches = [target_branch] + self.release_branches
327-
for iv in self._infer_fix_versions(branches, versions, infer_master):
328-
if iv["name"] not in resolved:
329-
resolved.append(iv["name"])
330-
return resolved
331-
except RuntimeError as e:
332-
print(f"Warning: failed to fetch JIRA versions: {e}", file=sys.stderr)
333-
return list(self.fix_versions)
334-
335334
def _comment_merge_summary(self, merged, sha):
336335
lines = [f"Merged into {merged[0]} ({_short_sha(sha)})."]
337336
for branch in merged[1:]:
@@ -342,7 +341,7 @@ def _comment_merge_summary(self, merged, sha):
342341
except RuntimeError as e:
343342
print(f"Warning: failed to comment on PR: {e}", file=sys.stderr)
344343

345-
def _do_resolve_jira(self, title, merged):
344+
def _do_resolve_jira(self, title, fix_ver):
346345
if not self.jira_token:
347346
raise RuntimeError("JIRA_ACCESS_TOKEN is not set")
348347

@@ -351,30 +350,14 @@ def _do_resolve_jira(self, title, merged):
351350
print("No JIRA ID found in PR title, skipping.")
352351
return
353352

354-
versions = self._jira_unreleased_versions()
355-
vm = {v["name"]: v for v in versions}
356-
357-
fix_ver, seen = [], set()
358-
for fv in self.fix_versions:
359-
if fv not in vm:
360-
raise RuntimeError(f'fix version "{fv}" not found')
361-
fix_ver.append(vm[fv])
362-
seen.add(fv)
363-
if versions:
364-
infer_master = not self.fix_versions
365-
for iv in self._infer_fix_versions(merged, versions, infer_master):
366-
if iv["name"] not in seen:
367-
fix_ver.append(iv)
368-
seen.add(iv["name"])
369-
370353
for jira_id in ids:
371354
try:
372355
issue = self._jira_get_issue(jira_id)
373356
except RuntimeError as e:
374357
print(f"Warning: get {jira_id}: {e}", file=sys.stderr)
375358
continue
376359
status = issue.get("fields", {}).get("status", {}).get("name", "")
377-
if status in ("Resolved", "Closed"):
360+
if status in JIRA_CLOSED_STATUSES:
378361
print(f'JIRA {jira_id} already "{status}", skipping.')
379362
continue
380363

@@ -383,9 +366,9 @@ def _do_resolve_jira(self, title, merged):
383366
print(f"Status: {status}")
384367

385368
transitions = self._jira_transitions(jira_id)
386-
resolve_id = next((t["id"] for t in transitions if t["name"] == "Resolve Issue"), None)
369+
resolve_id = next((t["id"] for t in transitions if t["name"] == JIRA_RESOLVE_TRANSITION), None)
387370
if not resolve_id:
388-
print(f"Warning: no 'Resolve Issue' transition for {jira_id}", file=sys.stderr)
371+
print(f"Warning: no '{JIRA_RESOLVE_TRANSITION}' transition for {jira_id}", file=sys.stderr)
389372
continue
390373

391374
jira_comment = (
@@ -413,6 +396,10 @@ def _short_sha(sha):
413396
return sha[:8] if len(sha) > 8 else sha
414397

415398

399+
def _pick_branch_name(pr_num, branch):
400+
return f"PR_TOOL_PICK_PR_{pr_num}_{branch.upper()}"
401+
402+
416403
def _standardize_title(text):
417404
text = text.rstrip(".")
418405
if text.startswith('Revert "') and text.endswith('"'):

0 commit comments

Comments
 (0)