3939GITHUB_API_BASE = "https://api.github.com/repos/apache/zeppelin"
4040JIRA_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+
4247JIRA_ID_RE = re .compile (r"ZEPPELIN-\d{3,6}" )
4348TITLE_FORMATTED_RE = re .compile (r"^\[ZEPPELIN-\d{3,6}](\[[A-Z0-9_\s,]+] )+\S+" )
4449TITLE_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+
416403def _standardize_title (text ):
417404 text = text .rstrip ("." )
418405 if text .startswith ('Revert "' ) and text .endswith ('"' ):
0 commit comments