Skip to content

Commit 2772807

Browse files
Push more into Python script
1 parent 617ff3e commit 2772807

2 files changed

Lines changed: 183 additions & 79 deletions

File tree

.claude/commands/review-lk.md

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,9 @@ Run `python3 REPO_ROOT/.claude/scripts/gather-review-diff.py --local`
2222

2323
**If `$ARGUMENTS` contains `/pull/` (GitHub PR URL):**
2424

25-
1. Run `gh pr view $ARGUMENTS` to get the PR title, description, and author.
26-
2. Extract the branch name and primary repo identity in one call:
27-
`gh pr view $ARGUMENTS --json headRefName,headRepository,headRepositoryOwner --jq '"branch=\(.headRefName) primary=\(.headRepositoryOwner.login)/\(.headRepository.name)"'`
28-
3. Run `gh pr diff $ARGUMENTS` to get the primary repo's diff (this is accurate to the PR's actual base branch).
29-
4. Run `python3 REPO_ROOT/.claude/scripts/gather-review-diff.py <branch> --skip <primary-owner/repo>` to collect diffs from any related repos that also have the same branch.
25+
1. Run `python3 REPO_ROOT/.claude/scripts/gather-review-diff.py --pr-url $ARGUMENTS`
26+
27+
The script fetches PR metadata and the primary diff internally and parallelizes all secondary repo checks, so no separate `gh` calls are needed.
3028

3129
---
3230

.claude/scripts/gather-review-diff.py

Lines changed: 180 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
#!/usr/bin/env python3
22
"""
33
Usage: 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
67
Branch mode: for every repo in the LabKey workspace that has BRANCH:
@@ -15,6 +16,10 @@
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+
1823
Local 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.
@@ -25,6 +30,7 @@
2530
import re
2631
import subprocess
2732
import sys
33+
from concurrent.futures import ThreadPoolExecutor
2834
from dataclasses import dataclass
2935
from 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

386492
if __name__ == "__main__":
387-
main()
493+
main()

0 commit comments

Comments
 (0)