Skip to content

Commit 989cebc

Browse files
pr_file_map.py: fetch PR files concurrently with asyncio
The slow 'first pass' was one sequential 'gh pr view' per open PR (~one network round-trip each), so it is I/O-bound. Fetch them concurrently with asyncio.create_subprocess_exec, bounded by a semaphore (default 10, override via PR_FILE_MAP_CONCURRENCY) to stay polite to the GitHub API. Output is unchanged and deterministic (results keyed back in PR order); errors still fail fast with the same messages.
1 parent a381578 commit 989cebc

1 file changed

Lines changed: 80 additions & 9 deletions

File tree

scripts/pr_file_map.py

Lines changed: 80 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
3232
Run status is also written to stderr with:
3333
- Number of PRs from `get_open_prs()`
34-
- Number of file touches from `get_pr_files()` and distinct files touched
34+
- Number of file touches from `get_pr_files_async()` and distinct files touched
3535
- Number of existing and missing files
3636
3737
Requirements: gh (GitHub CLI), authenticated (`gh auth login`)
@@ -41,7 +41,9 @@
4141
scripts/pr_file_map.py > report.md
4242
"""
4343

44+
import asyncio
4445
import json
46+
import os
4547
import shutil
4648
import subprocess
4749
import sys
@@ -51,6 +53,12 @@
5153

5254
DIRECTORY_FILE = "DIRECTORY.md"
5355

56+
# How many `gh pr view` calls to run concurrently. The slow "first pass" is one
57+
# network round-trip per open PR, so it is I/O-bound and gains a lot from
58+
# concurrency; the cap keeps us polite to the GitHub API and avoids secondary
59+
# rate limits. Override with the PR_FILE_MAP_CONCURRENCY environment variable.
60+
DEFAULT_CONCURRENCY = 10
61+
5462
# Open PRs to skip in the report, e.g. [123, 456, 789] ignores #123, #456, #789.
5563
ignore_pull_request: set[int] = {15105, 15142, 15356}
5664

@@ -118,12 +126,67 @@ def get_open_prs() -> list[dict]:
118126
return [pr for pr in json.loads(raw) if pr["number"] not in ignore]
119127

120128

121-
def get_pr_files(pr_number: int) -> list[str]:
122-
raw = run_gh(["pr", "view", str(pr_number), "--json", "files"])
129+
async def run_gh_async(args: list[str], semaphore: asyncio.Semaphore) -> str:
130+
"""Async counterpart of run_gh, throttled by a shared semaphore.
131+
132+
The semaphore bounds how many `gh` subprocesses run at once so we speed up
133+
the many-round-trip "first pass" without flooding the GitHub API.
134+
"""
135+
async with semaphore:
136+
try:
137+
proc = await asyncio.create_subprocess_exec(
138+
"gh",
139+
*args,
140+
stdout=asyncio.subprocess.PIPE,
141+
stderr=asyncio.subprocess.PIPE,
142+
)
143+
except FileNotFoundError:
144+
sys.exit("Error: 'gh' (GitHub CLI) is not installed or not in PATH.")
145+
stdout, stderr = await proc.communicate()
146+
if proc.returncode != 0:
147+
sys.exit(f"Error running 'gh {' '.join(args)}':\n{stderr.decode().strip()}")
148+
return stdout.decode()
149+
150+
151+
async def get_pr_files_async(pr_number: int, semaphore: asyncio.Semaphore) -> list[str]:
152+
raw = await run_gh_async(
153+
["pr", "view", str(pr_number), "--json", "files"], semaphore
154+
)
123155
data = json.loads(raw)
124156
return [f["path"] for f in data.get("files", [])]
125157

126158

159+
async def gather_pr_files(
160+
pr_numbers: list[int], concurrency: int
161+
) -> dict[int, list[str]]:
162+
"""Fetch each PR's file list concurrently, capped at ``concurrency``.
163+
164+
Returns a ``{pr_number: [paths]}`` mapping keyed in the same order as
165+
``pr_numbers`` so downstream output stays deterministic.
166+
"""
167+
semaphore = asyncio.Semaphore(concurrency)
168+
results = await asyncio.gather(
169+
*(get_pr_files_async(number, semaphore) for number in pr_numbers)
170+
)
171+
return dict(zip(pr_numbers, results))
172+
173+
174+
def resolve_concurrency() -> int:
175+
"""Read PR_FILE_MAP_CONCURRENCY (a positive int) or fall back to the default."""
176+
raw = os.environ.get("PR_FILE_MAP_CONCURRENCY")
177+
if raw is None:
178+
return DEFAULT_CONCURRENCY
179+
try:
180+
value = int(raw)
181+
except ValueError:
182+
value = 0
183+
if value < 1:
184+
sys.exit(
185+
f"Error: PR_FILE_MAP_CONCURRENCY must be a positive integer, got {raw!r}."
186+
)
187+
return value
188+
189+
127190
def split_directory_conflicts(
128191
directory_prs: list[int],
129192
pr_to_files: dict[int, list[str]],
@@ -209,19 +272,27 @@ def main() -> None:
209272
print(f"PR count from get_open_prs(): {pr_count}", file=sys.stderr)
210273

211274
file_to_prs: dict[str, list[int]] = defaultdict(list)
212-
pr_to_files: dict[int, list[str]] = {}
213275
touch_count = 0 # every (PR, file) pair; a file may be touched by many PRs
214276

215-
for pr in prs:
216-
pr_number = pr["number"]
217-
pr_files = get_pr_files(pr_number)
218-
pr_to_files[pr_number] = pr_files
277+
# First pass: one `gh pr view` per PR. This is the slow, network-bound part,
278+
# so fetch them concurrently (bounded by resolve_concurrency()).
279+
concurrency = resolve_concurrency()
280+
pr_numbers = [pr["number"] for pr in prs]
281+
print(
282+
f"Fetching files for {pr_count} PRs "
283+
f"(up to {concurrency} concurrent gh calls)...",
284+
file=sys.stderr,
285+
)
286+
pr_to_files = asyncio.run(gather_pr_files(pr_numbers, concurrency))
287+
288+
for pr_number in pr_numbers:
289+
pr_files = pr_to_files[pr_number]
219290
touch_count += len(pr_files)
220291
for path in pr_files:
221292
file_to_prs[path].append(pr_number)
222293
distinct_count = len(file_to_prs)
223294
print(
224-
f"File touches from get_pr_files(): {touch_count} "
295+
f"File touches from get_pr_files_async(): {touch_count} "
225296
f"across {distinct_count} distinct files",
226297
file=sys.stderr,
227298
)

0 commit comments

Comments
 (0)