@@ -46,7 +46,6 @@ from rich.progress import Progress
4646
4747if TYPE_CHECKING :
4848 from github .Issue import Issue
49- from github .PullRequest import PullRequest
5049
5150GIT_COMMIT_FIELDS = ["id" , "author_name" , "author_email" , "date" , "subject" , "body" ]
5251GIT_LOG_FORMAT = "%x1f" .join (["%h" , "%an" , "%ae" , "%ad" , "%s" , "%b" ]) + "%x1e"
@@ -95,22 +94,86 @@ def get_issue_type(issue):
9594 return issue_type
9695
9796
98- def get_commit_in_main_associated_with_pr (repo : git .Repo , issue : Issue ) -> str | None :
97+ def build_main_commits_cache (repo : git .Repo , issue_numbers : set [int ]) -> dict [int , str ]:
98+ """Build a cache of PR number -> main branch commit SHA with a single git log operation"""
99+ cache = {}
100+
101+ if not issue_numbers :
102+ return cache
103+
104+ try :
105+ # Single git log to get all main branch commits
106+ # Use a reasonable range to avoid scanning entire history
107+ try :
108+ # Try to get commits since last major version (should cover most PR ranges)
109+ log_output = repo .git .log ("origin/main" , "--format=%H %s" , "--since=1 year ago" )
110+ except Exception :
111+ # Fallback to last 2000 commits if date fails
112+ log_output = repo .git .log ("origin/main" , "--format=%H %s" , "-2000" )
113+
114+ # Use regex to find all PR numbers in commit messages at once
115+ import re
116+ pr_pattern = r"\(#(\d+)\)$" # PR number at end of commit message
117+
118+ for commit_line in log_output .splitlines ():
119+ if not commit_line :
120+ continue
121+
122+ # Find PR number at end of commit message
123+ match = re .search (pr_pattern , commit_line )
124+ if match :
125+ pr_number = int (match .group (1 ))
126+ if pr_number in issue_numbers :
127+ commit_sha = commit_line .split (" " )[0 ]
128+ cache [pr_number ] = commit_sha
129+
130+ except Exception :
131+ # Fallback to empty cache if git operation fails
132+ pass
133+
134+ return cache
135+
136+
137+ def get_commit_in_main_associated_with_pr (repo : git .Repo , issue : Issue , main_commits_cache : dict [int , str ]) -> str | None :
99138 """For a PR, find the associated merged commit & return its SHA"""
100139 if issue .pull_request :
101- log_output = repo .git .log (f"--grep=(#{ issue .number } )$" , "origin/main" , "--format=%H %s" )
102- if log_output :
103- for commit_line in log_output .splitlines ():
104- # We only want the commit for the PR where squash-merge added (#PR) at the end of subject
105- if commit_line and commit_line .endswith (f"(#{ issue .number } )" ):
106- return commit_line .split (" " )[0 ]
107- return None
108- pr : PullRequest = issue .as_pull_request ()
109- if pr .is_merged ():
110- return pr .merge_commit_sha
140+ # Use cache (should be pre-populated)
141+ return main_commits_cache .get (issue .number )
111142 return None
112143
113144
145+ def build_cherrypicked_cache (repo : git .Repo , issue_numbers : list [int ], previous_version : str | None = None ) -> dict [int , bool ]:
146+ """Build a cache of which issues are cherry-picked by doing a single git log operation"""
147+ cache = {num : False for num in issue_numbers }
148+
149+ if not issue_numbers :
150+ return cache
151+
152+ # Get all commits in range and process them
153+ log_args = ["--format=%H %s" ]
154+ if previous_version :
155+ log_args .append (previous_version + ".." )
156+
157+ try :
158+ log_output = repo .git .log (* log_args )
159+ # Use regex to find all PR numbers in the entire log output at once
160+ import re
161+ pr_pattern = r"\(#(\d+)\)"
162+
163+ for commit_line in log_output .splitlines ():
164+ # Find all PR numbers in this commit
165+ matches = re .findall (pr_pattern , commit_line )
166+ for match in matches :
167+ issue_num = int (match )
168+ if issue_num in cache :
169+ cache [issue_num ] = True
170+ except Exception :
171+ # Fallback to individual checks if batch fails
172+ pass
173+
174+ return cache
175+
176+
114177def is_cherrypicked (repo : git .Repo , issue : Issue , previous_version : str | None = None ) -> bool :
115178 """Check if a given issue is cherry-picked in the current branch or not"""
116179 log_args = ["--format=%H %s" , f"--grep=(#{ issue .number } )" ]
@@ -236,62 +299,128 @@ def cli():
236299 " searching for few commits to find the cherry-picked commits" ,
237300)
238301@click .option ("--unmerged" , "show_uncherrypicked_only" , help = "Show unmerged PRs only" , is_flag = True )
239- def compare (target_version , github_token , previous_version = None , show_uncherrypicked_only = False ):
302+
303+ @click .option ("--show-commits" , help = "Show commit SHAs (default: on, off when --unmerged)" , is_flag = True , default = None )
304+
305+ def compare (target_version , github_token , previous_version = None , show_uncherrypicked_only = False , show_commits = None ):
306+ # Set smart defaults
307+ if show_commits is None :
308+ show_commits = not show_uncherrypicked_only # Default off for --unmerged
309+
240310 repo = git .Repo ("." , search_parent_directories = True )
241311
242312 github_handler = Github (github_token )
243- milestone_issues : list [Issue ] = list (
313+
314+ # Fetch PRs and Issues separately, with merged PRs identified upfront
315+ merged_prs : list [Issue ] = list (
316+ github_handler .search_issues (
317+ f'repo:apache/airflow milestone:"Airflow { target_version } " is:pull-request is:merged'
318+ )
319+ )
320+ closed_prs : list [Issue ] = list (
321+ github_handler .search_issues (
322+ f'repo:apache/airflow milestone:"Airflow { target_version } " is:pull-request is:closed -is:merged'
323+ )
324+ )
325+ open_prs : list [Issue ] = list (
244326 github_handler .search_issues (
245- f'repo:apache/airflow milestone:"Airflow { target_version } " is:pull-request '
327+ f'repo:apache/airflow milestone:"Airflow { target_version } " is:pull-request is:open '
246328 )
247329 )
248- milestone_issues .extend (
249- list (
330+
331+ # Skip fetching issues if we only care about unmerged PRs
332+ if show_uncherrypicked_only :
333+ issues = []
334+ else :
335+ issues : list [Issue ] = list (
250336 github_handler .search_issues (
251- f'repo:apache/airflow milestone:"Airflow { target_version } " is:issue '
337+ f'repo:apache/airflow milestone:"Airflow { target_version } " is:issue'
252338 )
253339 )
254- )
340+
341+ # Create a merge status lookup
342+ pr_merge_status_cache = {}
343+ for pr in merged_prs :
344+ pr_merge_status_cache [pr .number ] = True
345+ for pr in closed_prs :
346+ pr_merge_status_cache [pr .number ] = False
347+ # Open PRs are neither merged nor closed, so we don't need to cache them
348+
349+ milestone_issues = merged_prs + closed_prs + open_prs + issues
255350
256351 num_cherrypicked = 0
257352 num_uncherrypicked = Counter ()
258353
259354 # :<18 says left align, pad to 18, :>6 says right align, pad to 6
260355 # :<50.50 truncates after 50 chars
261356 # !s forces as string
262- formatstr = (
263- "{number:>6} | {typ!s:<5} | {changelog!s:<13} | {status!s} "
264- "| {title:<83.83} | {merged:<6} | {commit:>7.7} | {url}"
265- )
266-
267- print (
268- formatstr .format (
269- number = "NUMBER" ,
270- typ = "TYPE" ,
271- changelog = "CHANGELOG" ,
272- status = "STATUS" .ljust (6 ),
273- title = "TITLE" ,
274- merged = "MERGED" ,
275- commit = "COMMIT" ,
276- url = "URL" ,
357+ if show_commits :
358+ formatstr = (
359+ "{number:>6} | {typ!s:<5} | {changelog!s:<13} | {status!s} "
360+ "| {title:<83.83} | {merged:<6} | {commit:>7.7} | {url}"
277361 )
278- )
362+ header_fields = {
363+ "number" : "NUMBER" ,
364+ "typ" : "TYPE" ,
365+ "changelog" : "CHANGELOG" ,
366+ "status" : "STATUS" .ljust (6 ),
367+ "title" : "TITLE" ,
368+ "merged" : "CHERRY" ,
369+ "commit" : "COMMIT" ,
370+ "url" : "URL" ,
371+ }
372+ else :
373+ formatstr = (
374+ "{number:>6} | {typ!s:<5} | {changelog!s:<13} | {status!s} "
375+ "| {title:<95.95} | {merged:<6} | {url}"
376+ )
377+ header_fields = {
378+ "number" : "NUMBER" ,
379+ "typ" : "TYPE" ,
380+ "changelog" : "CHANGELOG" ,
381+ "status" : "STATUS" .ljust (6 ),
382+ "title" : "TITLE" ,
383+ "merged" : "CHERRY" ,
384+ "commit" : "" , # Not used
385+ "url" : "URL" ,
386+ }
387+
388+ print (formatstr .format (** header_fields ))
279389 milestone_issues = sorted (
280390 milestone_issues , key = lambda x : x .closed_at if x .closed_at else x .created_at , reverse = True
281391 )
392+
393+ # Build caches for performance optimization
394+ issue_numbers = [issue .number for issue in milestone_issues if is_pr (issue )]
395+
396+ # Convert to set for O(1) lookups in cache building
397+ issue_numbers_set = set (issue_numbers )
398+
399+ # Build all caches upfront with batch operations
400+ if show_commits :
401+ main_commits_cache = build_main_commits_cache (repo , issue_numbers_set )
402+ else :
403+ main_commits_cache = {}
404+
405+ cherrypicked_cache = build_cherrypicked_cache (repo , issue_numbers , previous_version )
406+
282407 for issue in milestone_issues :
283- commit_in_main = get_commit_in_main_associated_with_pr (repo , issue )
284408 issue_is_pr = is_pr (issue )
285409
286- # Determine status - differentiate between Closed and Merged for PRs
410+ # Determine status - differentiate between Closed and Merged for PRs using cache
287411 if issue_is_pr and issue .state == "closed" :
288- pr = issue . as_pull_request ( )
289- status = "Merged" if pr . is_merged () else "Closed"
412+ is_merged = pr_merge_status_cache . get ( issue . number , False )
413+ status = "Merged" if is_merged else "Closed"
290414 else :
291415 status = issue .state .capitalize ()
292416
293- # Checks if commit was cherrypicked into branch.
294- if is_cherrypicked (repo , issue , previous_version ):
417+ # Checks if commit was cherrypicked into branch using cache
418+ if issue_is_pr :
419+ is_cherry_picked = cherrypicked_cache .get (issue .number , False )
420+ else :
421+ is_cherry_picked = is_cherrypicked (repo , issue , previous_version )
422+
423+ if is_cherry_picked :
295424 num_cherrypicked += 1
296425 if show_uncherrypicked_only :
297426 continue
@@ -314,9 +443,12 @@ def compare(target_version, github_token, previous_version=None, show_uncherrypi
314443 url = issue .html_url ,
315444 )
316445
317- print (
318- formatstr .format (** fields , merged = cherrypicked , commit = commit_in_main if commit_in_main else "" )
319- )
446+ # Only get commit info if we're showing commits
447+ if show_commits :
448+ commit_in_main = get_commit_in_main_associated_with_pr (repo , issue , main_commits_cache )
449+ fields ["commit" ] = commit_in_main if commit_in_main else ""
450+
451+ print (formatstr .format (** fields , merged = cherrypicked ))
320452
321453 print (
322454 f"Commits on branch: { num_cherrypicked :d} , { sum (num_uncherrypicked .values ()):d} "
0 commit comments