|
31 | 31 |
|
32 | 32 | Run status is also written to stderr with: |
33 | 33 | - 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 |
35 | 35 | - Number of existing and missing files |
36 | 36 |
|
37 | 37 | Requirements: gh (GitHub CLI), authenticated (`gh auth login`) |
|
41 | 41 | scripts/pr_file_map.py > report.md |
42 | 42 | """ |
43 | 43 |
|
| 44 | +import asyncio |
44 | 45 | import json |
| 46 | +import os |
45 | 47 | import shutil |
46 | 48 | import subprocess |
47 | 49 | import sys |
|
51 | 53 |
|
52 | 54 | DIRECTORY_FILE = "DIRECTORY.md" |
53 | 55 |
|
| 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 | + |
54 | 62 | # Open PRs to skip in the report, e.g. [123, 456, 789] ignores #123, #456, #789. |
55 | 63 | ignore_pull_request: set[int] = {15105, 15142, 15356} |
56 | 64 |
|
@@ -118,12 +126,67 @@ def get_open_prs() -> list[dict]: |
118 | 126 | return [pr for pr in json.loads(raw) if pr["number"] not in ignore] |
119 | 127 |
|
120 | 128 |
|
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 | + ) |
123 | 155 | data = json.loads(raw) |
124 | 156 | return [f["path"] for f in data.get("files", [])] |
125 | 157 |
|
126 | 158 |
|
| 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 | + |
127 | 190 | def split_directory_conflicts( |
128 | 191 | directory_prs: list[int], |
129 | 192 | pr_to_files: dict[int, list[str]], |
@@ -209,19 +272,27 @@ def main() -> None: |
209 | 272 | print(f"PR count from get_open_prs(): {pr_count}", file=sys.stderr) |
210 | 273 |
|
211 | 274 | file_to_prs: dict[str, list[int]] = defaultdict(list) |
212 | | - pr_to_files: dict[int, list[str]] = {} |
213 | 275 | touch_count = 0 # every (PR, file) pair; a file may be touched by many PRs |
214 | 276 |
|
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] |
219 | 290 | touch_count += len(pr_files) |
220 | 291 | for path in pr_files: |
221 | 292 | file_to_prs[path].append(pr_number) |
222 | 293 | distinct_count = len(file_to_prs) |
223 | 294 | print( |
224 | | - f"File touches from get_pr_files(): {touch_count} " |
| 295 | + f"File touches from get_pr_files_async(): {touch_count} " |
225 | 296 | f"across {distinct_count} distinct files", |
226 | 297 | file=sys.stderr, |
227 | 298 | ) |
|
0 commit comments