11#!/usr/bin/env python3
22"""
33Usage: gather-review-diff.py <branch-name> [--skip owner/repo]
4+ gather-review-diff.py --pr-url <GitHub-PR-URL>
45 gather-review-diff.py --local
56
67Branch mode: for every repo in the LabKey workspace that has BRANCH:
1516 --skip owner/repo Mark one repo already covered (e.g. by `gh pr diff`) so it
1617 appears in the summary but is not re-diffed.
1718
19+ PR URL mode (--pr-url): accept a GitHub PR URL; fetch all metadata and diffs internally.
20+ Absorbs the separate gh pr view / gh pr diff calls. Collection of related repos is
21+ parallelized so the primary diff and secondary checks run concurrently.
22+
1823Local mode (--local): diff working-tree changes across all repos in the workspace.
1924 No GitHub API calls are made. Runs `git diff HEAD` per repo (falls back to
2025 `git diff --cached` for repos with no commits yet). Excludes .idea and server/configs.
2530import re
2631import subprocess
2732import sys
33+ from concurrent .futures import ThreadPoolExecutor
2834from dataclasses import dataclass
2935from pathlib import Path
3036
@@ -170,8 +176,8 @@ def collect_repo(
170176 owner_repo = parse_owner_repo (remote_url )
171177
172178 if skip_repo and owner_repo == skip_repo :
173- # This repo's diff is already provided by ` gh pr diff` in the calling
174- # command; we acknowledge it in the summary but don't re-diff it.
179+ # This repo's diff is already provided externally ( gh pr diff or --pr-url);
180+ # acknowledge it in the summary but don't re-diff it.
175181 return owner_repo , "covered by PR diff" , None
176182
177183 # Check locally first (no network), then fall back to ls-remote.
@@ -279,94 +285,139 @@ def run_local_mode(repo_root: Path) -> None:
279285 print ()
280286
281287
282- def main () -> None :
283- parser = argparse .ArgumentParser (
284- description = "Collect diffs for a branch across the LabKey multi-repo workspace."
285- )
286- parser .add_argument ("branch" , nargs = "?" , help = "Feature branch name (omit with --local)" )
287- parser .add_argument (
288- "--local" , action = "store_true" ,
289- help = "Diff working-tree changes across all repos (no GitHub API calls)" ,
290- )
291- parser .add_argument (
292- "--skip" , metavar = "OWNER/REPO" , default = "" ,
293- help = "Omit one repo already covered by a PR diff" ,
288+ def fetch_pr_metadata (pr_url : str ) -> dict :
289+ """Fetch all needed PR fields in one gh pr view call."""
290+ stdout , ok = run (
291+ "gh" , "pr" , "view" , pr_url ,
292+ "--json" , "headRefName,headRepository,headRepositoryOwner,title,body,changedFiles,baseRefName" ,
294293 )
295- args = parser .parse_args ()
294+ if not ok or not stdout :
295+ print (f"Error: could not fetch PR metadata for { pr_url } " , file = sys .stderr )
296+ sys .exit (1 )
297+ try :
298+ return json .loads (stdout )
299+ except json .JSONDecodeError as e :
300+ print (f"Error: could not parse PR metadata: { e } " , file = sys .stderr )
301+ sys .exit (1 )
296302
297- if args .local and args .branch :
298- parser .error ("--local and branch are mutually exclusive" )
299- if not args .local and not args .branch :
300- parser .error ("branch is required unless --local is specified" )
301303
302- repo_root_str , ok = run ("git" , "rev-parse" , "--show-toplevel" )
303- if not ok :
304- print ("Error: not inside a git repository" , file = sys .stderr )
305- sys .exit (1 )
306- repo_root = Path (repo_root_str )
304+ def fetch_pr_diff (pr_url : str ) -> str :
305+ """Fetch the unified diff for a PR URL."""
306+ result = subprocess .run (
307+ ["gh" , "pr" , "diff" , pr_url ],
308+ capture_output = True ,
309+ text = True ,
310+ encoding = "utf-8" ,
311+ errors = "replace" ,
312+ )
313+ return result .stdout
307314
308- if args .local :
309- run_local_mode (repo_root )
310- return
311315
312- branch : str = args .branch
313- skip_repo : str = args .skip
316+ def _print_diff_block (
317+ owner_repo : str , branch : str , pr_info : PRInfo , diff : str
318+ ) -> None :
319+ print (f"=== { owner_repo } branch: { branch } base: { pr_info .base_branch } ===" )
320+ if pr_info .title :
321+ print (f"PR: { pr_info .title } " )
322+ if pr_info .body and pr_info .body .strip ():
323+ print ()
324+ print (pr_info .body .strip ())
325+ print ()
326+ if diff :
327+ print (diff , end = "" )
328+ print ()
314329
315- summary : list [tuple [str , str ]] = [] # (owner_repo, status_line)
316- diff_targets : list [tuple [str , PRInfo ]] = [] # repos to diff, with PR context
317- seen : set [str ] = set () # owner_repo values already processed
318330
319- # Pass 1a: locally cloned repos.
320- for path in workspace_paths (repo_root ):
321- if not path .exists ():
322- continue
323- owner_repo , status , pr_info = collect_repo (path , branch , skip_repo )
324- if owner_repo is None :
325- continue
326- seen .add (owner_repo )
331+ def run_branch_mode (
332+ branch : str ,
333+ skip_repo : str ,
334+ repo_root : Path ,
335+ pr_url : str = "" ,
336+ primary_owner_repo : str = "" ,
337+ primary_pr_info : PRInfo | None = None ,
338+ primary_changed_files : int | None = None ,
339+ ) -> None :
340+ """
341+ Collect diffs for BRANCH across all repos in the workspace.
342+
343+ When pr_url is set (--pr-url mode), the primary repo's diff is fetched
344+ internally and printed first; skip_repo should equal primary_owner_repo.
345+ All network-bound collection tasks run in parallel via ThreadPoolExecutor.
346+ """
347+ paths = [p for p in workspace_paths (repo_root ) if p .exists ()]
348+
349+ with ThreadPoolExecutor (max_workers = 10 ) as executor :
350+ # Kick off independent network tasks immediately so they overlap with
351+ # each other and with the local-repo git checks.
352+ topic_future = executor .submit (get_topic_repos )
353+ pr_diff_future = executor .submit (fetch_pr_diff , pr_url ) if pr_url else None
354+
355+ local_futures = [
356+ (p , executor .submit (collect_repo , p , branch , skip_repo ))
357+ for p in paths
358+ ]
359+
360+ # Drain local futures first (preserves workspace ordering) so we can
361+ # build the `seen` set before submitting remote futures.
362+ seen : set [str ] = set ()
363+ local_results : list [tuple [str , str , PRInfo | None ]] = []
364+ for _ , f in local_futures :
365+ owner_repo , status , pr_info = f .result ()
366+ if owner_repo :
367+ seen .add (owner_repo )
368+ local_results .append ((owner_repo , status , pr_info ))
369+
370+ # Remote checks — only repos not already covered by a local checkout.
371+ topic_repos = topic_future .result ()
372+ remote_futures = [
373+ (r , executor .submit (collect_remote_repo , r , branch , skip_repo ))
374+ for r in topic_repos
375+ if r not in seen
376+ ]
377+
378+ remote_results : list [tuple [str , str , PRInfo | None ]] = []
379+ for owner_repo , f in remote_futures :
380+ owner_repo_out , status , pr_info = f .result ()
381+ if owner_repo_out :
382+ remote_results .append ((owner_repo_out , status , pr_info ))
383+
384+ primary_diff = pr_diff_future .result () if pr_diff_future else ""
385+
386+ # Build ordered summary and diff-target lists.
387+ summary : list [tuple [str , str ]] = []
388+ diff_targets : list [tuple [str , PRInfo ]] = []
389+
390+ if primary_owner_repo and primary_pr_info is not None :
391+ count = str (primary_changed_files ) if primary_changed_files is not None else "?"
392+ primary_status = f"{ count } files changed (base: { primary_pr_info .base_branch } )"
393+ if primary_pr_info .title :
394+ primary_status += f" — { primary_pr_info .title } "
395+ summary .append ((primary_owner_repo , primary_status ))
396+
397+ for owner_repo , status , pr_info in local_results :
398+ if owner_repo == primary_owner_repo :
399+ continue # Already represented above
327400 summary .append ((owner_repo , status ))
328401 if pr_info is not None :
329402 diff_targets .append ((owner_repo , pr_info ))
330403
331- # Pass 1b: GitHub repos tagged labkey-module-container that aren't cloned locally.
332- # These are checked purely via the GitHub API — no local git operations needed.
333- for owner_repo in get_topic_repos ():
334- if owner_repo in seen :
335- # Already handled in pass 1a via the local checkout; skip to avoid
336- # a duplicate summary entry and a redundant API diff fetch.
337- continue
338- seen .add (owner_repo )
339- owner_repo_out , status , pr_info = collect_remote_repo (owner_repo , branch , skip_repo )
340- if owner_repo_out is None :
341- continue
342- summary .append ((owner_repo_out , status ))
404+ for owner_repo , status , pr_info in remote_results :
405+ summary .append ((owner_repo , status ))
343406 if pr_info is not None :
344- diff_targets .append ((owner_repo_out , pr_info ))
407+ diff_targets .append ((owner_repo , pr_info ))
345408
346409 # Print summary table.
347410 print (f"=== Repos examined for branch: { branch } ===" )
348411 for repo , status in summary :
349412 print (f" { repo :<45} { status } " )
350413 print ()
351414
352- # Pass 2: for each repo, print any PR context as a preamble then stream the diff.
353- # The preamble gives the reviewer intent context per repo without needing to
354- # look up each PR separately.
355- #
356- # Note: when called from /review-lk with a PR URL, the primary repo is passed
357- # via --skip (its description is already in context from `gh pr view`), so the
358- # preamble only appears for secondary repos in that flow. In bare-branch mode
359- # it appears for every repo that has an open PR for the branch.
360- for owner_repo , pr_info in diff_targets :
361- print (f"=== { owner_repo } branch: { branch } base: { pr_info .base_branch } ===" )
362-
363- if pr_info .title :
364- print (f"PR: { pr_info .title } " )
365- if pr_info .body and pr_info .body .strip ():
366- print ()
367- print (pr_info .body .strip ())
368- print ()
415+ # Primary repo diff comes first when in --pr-url mode.
416+ if primary_owner_repo and primary_pr_info is not None :
417+ _print_diff_block (primary_owner_repo , branch , primary_pr_info , primary_diff )
369418
419+ # Secondary repo diffs.
420+ for owner_repo , pr_info in diff_targets :
370421 result = subprocess .run (
371422 [
372423 "gh" , "api" ,
@@ -378,10 +429,65 @@ def main() -> None:
378429 encoding = "utf-8" ,
379430 errors = "replace" ,
380431 )
381- if result .stdout :
382- print (result .stdout , end = "" )
383- print ()
432+ _print_diff_block (owner_repo , branch , pr_info , result .stdout )
433+
434+
435+ def main () -> None :
436+ parser = argparse .ArgumentParser (
437+ description = "Collect diffs for a branch across the LabKey multi-repo workspace."
438+ )
439+ parser .add_argument ("branch" , nargs = "?" , help = "Feature branch name (omit with --local or --pr-url)" )
440+ parser .add_argument (
441+ "--local" , action = "store_true" ,
442+ help = "Diff working-tree changes across all repos (no GitHub API calls)" ,
443+ )
444+ parser .add_argument (
445+ "--pr-url" , metavar = "URL" ,
446+ help = "GitHub PR URL — fetch metadata and diff internally (replaces separate gh pr view/diff calls)" ,
447+ )
448+ parser .add_argument (
449+ "--skip" , metavar = "OWNER/REPO" , default = "" ,
450+ help = "Omit one repo already covered by a PR diff" ,
451+ )
452+ args = parser .parse_args ()
453+
454+ if sum ([bool (args .local ), bool (args .pr_url ), bool (args .branch )]) != 1 :
455+ parser .error ("Provide exactly one of: branch, --local, or --pr-url" )
456+
457+ repo_root_str , ok = run ("git" , "rev-parse" , "--show-toplevel" )
458+ if not ok :
459+ print ("Error: not inside a git repository" , file = sys .stderr )
460+ sys .exit (1 )
461+ repo_root = Path (repo_root_str )
462+
463+ if args .local :
464+ run_local_mode (repo_root )
465+ return
466+
467+ if args .pr_url :
468+ pr_data = fetch_pr_metadata (args .pr_url )
469+ branch = pr_data ["headRefName" ]
470+ primary_owner_repo = (
471+ f"{ pr_data ['headRepositoryOwner' ]['login' ]} /{ pr_data ['headRepository' ]['name' ]} "
472+ )
473+ primary_pr_info = PRInfo (
474+ base_branch = pr_data ["baseRefName" ],
475+ title = pr_data .get ("title" , "" ),
476+ body = pr_data .get ("body" , "" ),
477+ )
478+ run_branch_mode (
479+ branch = branch ,
480+ skip_repo = primary_owner_repo ,
481+ repo_root = repo_root ,
482+ pr_url = args .pr_url ,
483+ primary_owner_repo = primary_owner_repo ,
484+ primary_pr_info = primary_pr_info ,
485+ primary_changed_files = pr_data .get ("changedFiles" ),
486+ )
487+ return
488+
489+ run_branch_mode (branch = args .branch , skip_repo = args .skip , repo_root = repo_root )
384490
385491
386492if __name__ == "__main__" :
387- main ()
493+ main ()
0 commit comments