11# Copyright (c) Microsoft Corporation. All rights reserved.
22# Licensed under the MIT License.
3- #
4- # Usage:
5- # python create_cherry_pick.py --label "release:1.24.2" --output cherry_pick.cmd --branch "origin/rel-1.24.2"
6- #
7- # Arguments:
8- # --label: Label to filter PRs (required)
9- # --output: Output cmd file path (required)
10- # --repo: Repository (default: microsoft/onnxruntime)
11- # --branch: Target branch to compare against for dependency checks (default: HEAD)
12- #
13- # This script fetches merged PRs with the specified label from the onnxruntime repository,
14- # sorts them by merge date, and generates:
15- # 1. A batch file (specified by --output) containing git cherry-pick commands.
16- # 2. A markdown file (cherry_pick_pr_description.md) summarizing the cherry-picked PRs for pull request description.
17- #
18- # It also checks for potential missing dependencies (conflicts) by verifying if files modified
19- # by the cherry-picked commits have any other modifications in the target branch history
20- # that are not included in the cherry-pick list.
3+
4+ """
5+ Cherry-Pick Helper Script
6+ -------------------------
7+ Description:
8+ This script automates the process of cherry-picking commits for a release branch.
9+ It fetches merged PRs with a specific label, sorts them by merge date, and generates:
10+ 1. A batch file (.cmd) with git cherry-pick commands.
11+ 2. A markdown file (.md) for the PR description.
12+ It also checks for potential missing dependencies (conflicts) by verifying if files modified
13+ by the cherry-picked commits have any other modifications in commits that are not in the
14+ specified target branch and are not included in the cherry-pick list.
15+
16+ Usage:
17+ python cherry_pick.py --label "release:1.24.2" --output cherry_pick.cmd --branch "origin/rel-1.24.2"
18+
19+ Requirements:
20+ - Python 3.7+
21+ - GitHub CLI (gh) logged in.
22+ - Git available in PATH.
23+ """
24+
2125import argparse
2226import json
2327import subprocess
2428import sys
2529from collections import defaultdict
2630
2731
28- def main ():
29- parser = argparse .ArgumentParser (description = "Generate cherry-pick script from PRs with a specific label." )
30- parser .add_argument ("--label" , required = True , help = "Label to filter PRs" )
31- parser .add_argument ("--output" , required = True , help = "Output cmd file path" )
32- parser .add_argument ("--repo" , default = "microsoft/onnxruntime" , help = "Repository (default: microsoft/onnxruntime)" )
33- parser .add_argument (
34- "--branch" , default = "HEAD" , help = "Target branch to compare against for dependency checks (default: HEAD)"
35- )
36- args = parser .parse_args ()
37-
38- # Fetch merged PRs with the specified label using gh CLI
39- print (f"Fetching merged PRs with label '{ args .label } ' from { args .repo } ..." )
32+ def run_command (command_list , cwd = None , silent = False ):
33+ """Run a command using a list of arguments for security (no shell=True)."""
34+ try :
35+ result = subprocess .run (command_list , check = False , capture_output = True , text = True , cwd = cwd , encoding = "utf-8" )
36+ if result .returncode != 0 :
37+ if not silent :
38+ log_str = " " .join (command_list )
39+ print (f"Error running command: { log_str } " , file = sys .stderr )
40+ if result .stderr :
41+ print (f"Stderr: { result .stderr .strip ()} " , file = sys .stderr )
42+ return None
43+ return result .stdout
44+ except FileNotFoundError :
45+ if not silent :
46+ cmd = command_list [0 ]
47+ print (f"Error: '{ cmd } ' command not found." , file = sys .stderr )
48+ if cmd == "gh" :
49+ print (
50+ "Please install GitHub CLI (https://cli.github.com/) and ensure 'gh' is available on your PATH." ,
51+ file = sys .stderr ,
52+ )
53+ return None
54+ except Exception as e :
55+ if not silent :
56+ print (f"Exception running command { ' ' .join (command_list )} : { e } " , file = sys .stderr )
57+ return None
58+
59+
60+ def check_preflight ():
61+ """Verify gh CLI and git repository early."""
62+ # Check git
63+ git_check = run_command (["git" , "rev-parse" , "--is-inside-work-tree" ], silent = True )
64+ if not git_check :
65+ print ("Error: This script must be run inside a git repository." , file = sys .stderr )
66+ return False
67+
68+ # Check gh
69+ gh_check = run_command (["gh" , "--version" ], silent = True )
70+ if not gh_check :
71+ print ("Error: GitHub CLI (gh) not found or not in PATH." , file = sys .stderr )
72+ print (
73+ "Please install GitHub CLI (https://cli.github.com/) and ensure 'gh' is available on your PATH." ,
74+ file = sys .stderr ,
75+ )
76+ return False
77+
78+ gh_auth = run_command (["gh" , "auth" , "status" ], silent = True )
79+ if not gh_auth :
80+ print ("Error: GitHub CLI not authenticated. Please run 'gh auth login'." , file = sys .stderr )
81+ return False
82+
83+ return True
84+
85+
86+ def get_merged_prs (repo , label , limit = 200 ):
87+ """Fetch merged PRs with the specific label."""
88+ print (f"Fetching merged PRs with label '{ label } ' from { repo } ..." )
4089 cmd = [
4190 "gh" ,
4291 "pr" ,
4392 "list" ,
4493 "--repo" ,
45- args . repo ,
94+ repo ,
4695 "--label" ,
47- args . label ,
96+ label ,
4897 "--state" ,
4998 "merged" ,
5099 "--json" ,
51100 "number,title,mergeCommit,mergedAt" ,
52101 "-L" ,
53- "200" ,
102+ str ( limit ) ,
54103 ]
104+ output = run_command (cmd )
105+ if not output :
106+ return []
55107
56108 try :
57- result = subprocess .run (cmd , capture_output = True , text = True , check = True )
58- prs = json .loads (result .stdout )
59- except subprocess .CalledProcessError as e :
60- print (f"Error running gh command: { e } " , file = sys .stderr )
61- print (e .stderr , file = sys .stderr )
62- sys .exit (1 )
109+ return json .loads (output )
63110 except json .JSONDecodeError as e :
64111 print (f"Error parsing gh output: { e } " , file = sys .stderr )
65- print (f"Output was: { result .stdout } " , file = sys .stderr )
66- sys .exit (1 )
112+ return []
113+
114+
115+ def get_changed_files (oid ):
116+ """Get list of files changed in a commit."""
117+ output = run_command (["git" , "diff-tree" , "--no-commit-id" , "--name-only" , "-r" , oid ], silent = True )
118+ if output :
119+ return output .strip ().splitlines ()
120+ return []
121+
122+
123+ def check_missing_dependencies (prs , branch ):
124+ """Check for potential missing dependencies (conflicts)."""
125+ print ("\n Checking for potential missing dependencies (conflicts)..." )
126+
127+ # Collect OIDs being cherry-picked
128+ cherry_pick_oids = set ()
129+ for pr in prs :
130+ if pr .get ("mergeCommit" ):
131+ cherry_pick_oids .add (pr ["mergeCommit" ]["oid" ])
132+
133+ for pr in prs :
134+ if not pr .get ("mergeCommit" ):
135+ continue
136+
137+ oid = pr ["mergeCommit" ]["oid" ]
138+ number = pr ["number" ]
139+
140+ files = get_changed_files (oid )
141+ if not files :
142+ continue
143+
144+ # For each file, find commits that modified it between the target branch and the cherry-picked commit.
145+ # Deduplicate warnings: group affected files by missing commit.
146+ # missing_commits maps: missing_commit_oid -> (title, [list of affected files])
147+ missing_commits = defaultdict (lambda : ("" , []))
148+
149+ for filepath in files :
150+ # git log <cherry-pick-commit> --not <target-branch> -- <file>
151+ output = run_command (["git" , "log" , oid , "--not" , branch , "--format=%H %s" , "--" , filepath ], silent = True )
152+
153+ if not output :
154+ continue
155+
156+ for line in output .strip ().splitlines ():
157+ parts = line .split (" " , 1 )
158+ c = parts [0 ]
159+ title = parts [1 ] if len (parts ) > 1 else ""
160+
161+ if c == oid :
162+ continue
163+ if c not in cherry_pick_oids :
164+ existing_title , existing_files = missing_commits [c ]
165+ if not existing_title :
166+ existing_title = title
167+ existing_files .append (filepath )
168+ missing_commits [c ] = (existing_title , existing_files )
169+
170+ # Print deduplicated warnings
171+ for missing_oid , (title , affected_files ) in missing_commits .items ():
172+ files_str = ", " .join (affected_files )
173+ print (
174+ f"WARNING: PR #{ number } ({ oid } ) modifies files that were also changed by commit { missing_oid } ({ title } ), "
175+ f"which is not in the cherry-pick list. This may indicate missing related changes. Affected files: { files_str } "
176+ )
177+
178+
179+ def main ():
180+ parser = argparse .ArgumentParser (description = "Generate cherry-pick script from PRs with a specific label." )
181+ parser .add_argument ("--label" , required = True , help = "Label to filter PRs" )
182+ parser .add_argument ("--output" , required = True , help = "Output cmd file path" )
183+ parser .add_argument ("--repo" , default = "microsoft/onnxruntime" , help = "Repository (default: microsoft/onnxruntime)" )
184+ parser .add_argument (
185+ "--branch" , default = "HEAD" , help = "Target branch to compare against for dependency checks (default: HEAD)"
186+ )
187+ parser .add_argument ("--limit" , type = int , default = 200 , help = "Wait limitation for PR fetching (default: 200)" )
188+ args = parser .parse_args ()
189+
190+ # Preflight Check
191+ if not check_preflight ():
192+ return
193+
194+ # 1. Fetch Merged PRs
195+ prs = get_merged_prs (args .repo , args .label , args .limit )
67196
68197 if not prs :
69198 print (f"No PRs found with label '{ args .label } '." )
@@ -72,7 +201,7 @@ def main():
72201 # Sort by mergedAt (ISO 8601 strings sort correctly in chronological order)
73202 prs .sort (key = lambda x : x ["mergedAt" ])
74203
75- # Write to output cmd file
204+ # 2. Write Output Script
76205 commit_count = 0
77206 with open (args .output , "w" , encoding = "utf-8" ) as f :
78207 f .write ("@echo off\n " )
@@ -95,84 +224,22 @@ def main():
95224
96225 print (f"Generated { args .output } with { commit_count } commits." )
97226
98- # Write to markdown file. You can use it as the pull request description.
227+ # 3. Write PR Description Markdown
99228 md_output = "cherry_pick_pr_description.md"
100229 with open (md_output , "w" , encoding = "utf-8" ) as f :
101230 f .write ("This cherry-picks the following commits for the release:\n " )
102231 for pr in prs :
103232 if not pr .get ("mergeCommit" ):
104233 continue
105234 number = pr ["number" ]
106- f .write (f"- #{ number } \n " )
235+ title = pr ["title" ]
236+ # Markdown link format: - #123 Title
237+ f .write (f"- #{ number } { title } \n " )
107238
108239 print (f"Generated { md_output } with { commit_count } commits." )
109240
110- # Check for potential missing dependencies
111- print ("\n Checking for potential missing dependencies (conflicts)..." )
112-
113- # Collect OIDs being cherry-picked
114- cherry_pick_oids = set ()
115- for pr in prs :
116- if pr .get ("mergeCommit" ):
117- cherry_pick_oids .add (pr ["mergeCommit" ]["oid" ])
118-
119- for pr in prs :
120- if not pr .get ("mergeCommit" ):
121- continue
122-
123- oid = pr ["mergeCommit" ]["oid" ]
124- number = pr ["number" ]
125-
126- # Get files changed by this commit
127- try :
128- res = subprocess .run (
129- ["git" , "diff-tree" , "--no-commit-id" , "--name-only" , "-r" , oid ],
130- capture_output = True ,
131- text = True ,
132- check = True ,
133- )
134- files = res .stdout .strip ().splitlines ()
135- except subprocess .CalledProcessError as e :
136- print (f"Error getting changed files for { oid } : { e } " , file = sys .stderr )
137- continue
138-
139- # For each file, find commits that modified it between the target branch and the cherry-picked commit.
140- # Deduplicate warnings: group affected files by missing commit.
141- # missing_commits maps: missing_commit_oid -> (title, [list of affected files])
142- missing_commits = defaultdict (lambda : ("" , []))
143- for filepath in files :
144- try :
145- res = subprocess .run (
146- ["git" , "log" , oid , "--not" , args .branch , "--format=%H %s" , "--" , filepath ],
147- capture_output = True ,
148- text = True ,
149- check = True ,
150- )
151- for line in res .stdout .strip ().splitlines ():
152- parts = line .split (" " , 1 )
153- c = parts [0 ]
154- title = parts [1 ] if len (parts ) > 1 else ""
155-
156- if c == oid :
157- continue
158- if c not in cherry_pick_oids :
159- existing_title , existing_files = missing_commits [c ]
160- if not existing_title :
161- existing_title = title
162- existing_files .append (filepath )
163- missing_commits [c ] = (existing_title , existing_files )
164-
165- except subprocess .CalledProcessError as e :
166- print (f"Error checking history for { filepath } : { e } " , file = sys .stderr )
167- continue
168-
169- # Print deduplicated warnings
170- for missing_oid , (title , affected_files ) in missing_commits .items ():
171- files_str = ", " .join (affected_files )
172- print (
173- f"WARNING: PR #{ number } ({ oid } ) depends on commit { missing_oid } ({ title } ) "
174- f"which is not in the cherry-pick list. Affected files: { files_str } "
175- )
241+ # 4. Dependency Check
242+ check_missing_dependencies (prs , args .branch )
176243
177244
178245if __name__ == "__main__" :
0 commit comments