Skip to content

Commit 6b8daed

Browse files
perf: async fan-out + progress reporting for hacktoberfest prep tracker
The daily prep job spent ~16 minutes because it looked up the changed files of every open 'awaiting reviews' PR one request at a time. Those lookups are independent, so fire them concurrently through a single httpx2.AsyncClient bounded by a small semaphore, and likewise resolve the tracked-row PR states and the issue/PR counts concurrently. Report progress to stderr (flushed) so a human watching the Actions log can see the job is alive. Runtime drops from ~16 min of serial round-trips to well under a minute.
1 parent 1eeed73 commit 6b8daed

1 file changed

Lines changed: 147 additions & 60 deletions

File tree

scripts/hacktoberfest_prep_update.py

Lines changed: 147 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,19 @@
1515
3. Exits non-zero once Hacktoberfest 2026 has begun (on or after
1616
2026-10-01, UTC), so the prep window closing is loud rather than silent.
1717
18-
It only uses the standard library and the ``GITHUB_TOKEN`` provided by the
19-
Actions runner, so there is nothing to install.
18+
The slow part of the job is looking at the files touched by every open
19+
``awaiting reviews`` PR (potentially a few hundred of them). Those requests
20+
have no dependencies on one another, so they are fired concurrently through a
21+
single ``httpx2.AsyncClient`` with a bounded concurrency limit — this turns a
22+
long series of round-trips into a handful of batches and cuts the runtime from
23+
~16 minutes to well under a minute. Progress is reported to stderr as it goes
24+
so a human watching the Actions log can see it is alive.
25+
26+
It only needs ``httpx2`` (the repo-standard HTTP client) and the
27+
``GITHUB_TOKEN`` provided by the Actions runner.
2028
"""
2129

30+
import asyncio
2231
import datetime as dt
2332
import os
2433
import re
@@ -34,102 +43,166 @@
3443
AWAITING_LABEL = "awaiting reviews"
3544
HACKTOBERFEST_START = dt.date(2026, 10, 1)
3645

46+
# How many API requests to keep in flight at once. GitHub's authenticated
47+
# primary limit is 5000/hour, but bursts of concurrent requests can trip the
48+
# secondary limits, so keep this modest.
49+
CONCURRENCY = 8
50+
3751
# A tracked row looks like: ``12. [ ] #15144 awaiting reviews``
3852
ROW_RE = re.compile(
3953
r"^(?P<idx>\d+)\.\s+\[(?P<mark>[ x])\]\s+#(?P<pr>\d+)\b(?P<rest>.*)$"
4054
)
4155
STATS_HEADER = "## Automated statistics"
4256

4357

44-
def _request(url: str, params: dict | None = None) -> tuple[dict | list, dict]:
45-
"""GET ``url`` and return ``(json_body, headers)``, retrying on 403/rate limit."""
58+
def _log(message: str) -> None:
59+
"""Emit a progress line to stderr, flushed so Actions shows it live."""
60+
print(message, file=sys.stderr, flush=True)
61+
62+
63+
def _headers() -> dict[str, str]:
4664
headers = {
4765
"Accept": "application/vnd.github+json",
4866
"X-GitHub-Api-Version": "2022-11-28",
4967
"User-Agent": "hacktoberfest-prep-bot",
5068
}
5169
if TOKEN:
5270
headers["Authorization"] = f"Bearer {TOKEN}"
53-
for attempt in range(4):
54-
resp = httpx2.get(url, params=params, headers=headers, timeout=30)
55-
if resp.is_success:
56-
return resp.json(), dict(resp.headers)
57-
remaining = resp.headers.get("X-RateLimit-Remaining")
58-
if resp.status_code in (403, 429) and remaining == "0":
59-
reset = int(resp.headers.get("X-RateLimit-Reset", "0"))
60-
wait = max(1, reset - int(time.time())) + 1
61-
print(f"Rate limited; sleeping {wait}s", file=sys.stderr)
62-
time.sleep(min(wait, 90))
63-
continue
64-
if resp.status_code >= 500 and attempt < 3:
65-
time.sleep(2 * (attempt + 1))
66-
continue
67-
resp.raise_for_status()
68-
msg = f"giving up on {url}"
69-
raise RuntimeError(msg)
71+
return headers
7072

7173

72-
def _search_count(query: str) -> int:
73-
body, _ = _request(f"{API}/search/issues", {"q": query, "per_page": 1})
74+
async def _request(
75+
client: httpx2.AsyncClient,
76+
sem: asyncio.Semaphore,
77+
url: str,
78+
params: dict | None = None,
79+
) -> tuple[dict | list, dict]:
80+
"""GET ``url`` and return ``(json_body, headers)``, retrying on 403/rate limit.
81+
82+
The semaphore bounds how many of these run concurrently.
83+
"""
84+
async with sem:
85+
for attempt in range(4):
86+
resp = await client.get(url, params=params)
87+
if resp.is_success:
88+
return resp.json(), dict(resp.headers)
89+
remaining = resp.headers.get("X-RateLimit-Remaining")
90+
if resp.status_code in (403, 429) and remaining == "0":
91+
reset = int(resp.headers.get("X-RateLimit-Reset", "0"))
92+
wait = max(1, reset - int(time.time())) + 1
93+
_log(f"Rate limited on {url}; sleeping {min(wait, 90)}s")
94+
await asyncio.sleep(min(wait, 90))
95+
continue
96+
if resp.status_code >= 500 and attempt < 3:
97+
await asyncio.sleep(2 * (attempt + 1))
98+
continue
99+
resp.raise_for_status()
100+
msg = f"giving up on {url}"
101+
raise RuntimeError(msg)
102+
103+
104+
async def _search_count(
105+
client: httpx2.AsyncClient, sem: asyncio.Semaphore, query: str
106+
) -> int:
107+
body, _ = await _request(
108+
client, sem, f"{API}/search/issues", {"q": query, "per_page": 1}
109+
)
74110
return int(body.get("total_count", 0)) # type: ignore[union-attr]
75111

76112

77-
def pr_state(number: int) -> str | None:
113+
async def pr_state(
114+
client: httpx2.AsyncClient, sem: asyncio.Semaphore, number: int
115+
) -> str | None:
78116
"""Return ``"merged"`` / ``"closed"`` for a resolved PR, else ``None``."""
79-
body, _ = _request(f"{API}/repos/{REPO}/pulls/{number}")
117+
body, _ = await _request(client, sem, f"{API}/repos/{REPO}/pulls/{number}")
80118
if body.get("state") == "open": # type: ignore[union-attr]
81119
return None
82120
return "merged" if body.get("merged_at") else "closed" # type: ignore[union-attr]
83121

84122

85-
def top_awaiting_directories(
86-
limit: int = 3, max_prs: int = 400
123+
async def top_awaiting_directories(
124+
client: httpx2.AsyncClient,
125+
sem: asyncio.Semaphore,
126+
limit: int = 3,
127+
max_prs: int = 400,
87128
) -> list[tuple[str, int]]:
88129
"""Count open ``awaiting reviews`` PRs by the top-level directory they touch."""
89130
query = f'repo:{REPO} is:pr is:open label:"{AWAITING_LABEL}"'
90-
counts: dict[str, int] = {}
131+
numbers: list[int] = []
91132
page = 1
92-
scanned = 0
93-
while scanned < max_prs:
94-
body, _ = _request(
133+
while len(numbers) < max_prs:
134+
body, _ = await _request(
135+
client,
136+
sem,
95137
f"{API}/search/issues",
96138
{"q": query, "per_page": 100, "page": page},
97139
)
98140
items = body.get("items", []) # type: ignore[union-attr]
99141
if not items:
100142
break
101-
for item in items:
102-
number = item["number"]
103-
files, _ = _request(
104-
f"{API}/repos/{REPO}/pulls/{number}/files", {"per_page": 100}
105-
)
106-
dirs = set()
107-
for changed in files: # type: ignore[union-attr]
108-
parts = changed["filename"].split("/")
109-
if len(parts) > 1 and not parts[0].startswith("."):
110-
dirs.add(parts[0])
111-
for directory in dirs:
112-
counts[directory] = counts.get(directory, 0) + 1
113-
scanned += 1
114-
if scanned >= max_prs:
115-
break
143+
numbers.extend(item["number"] for item in items)
116144
if len(items) < 100:
117145
break
118146
page += 1
147+
numbers = numbers[:max_prs]
148+
149+
total = len(numbers)
150+
_log(f"Scanning changed files for {total} '{AWAITING_LABEL}' PR(s)...")
151+
done = 0
152+
153+
async def dirs_for(number: int) -> set[str]:
154+
nonlocal done
155+
files, _ = await _request(
156+
client, sem, f"{API}/repos/{REPO}/pulls/{number}/files", {"per_page": 100}
157+
)
158+
dirs = set()
159+
for changed in files: # type: ignore[union-attr]
160+
parts = changed["filename"].split("/")
161+
if len(parts) > 1 and not parts[0].startswith("."):
162+
dirs.add(parts[0])
163+
done += 1
164+
if done % 25 == 0 or done == total:
165+
_log(f" ...scanned {done}/{total} PR(s)")
166+
return dirs
167+
168+
results = await asyncio.gather(*(dirs_for(n) for n in numbers))
169+
170+
counts: dict[str, int] = {}
171+
for dirs in results:
172+
for directory in dirs:
173+
counts[directory] = counts.get(directory, 0) + 1
119174
ranked = sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))
120175
return ranked[:limit]
121176

122177

123-
def refresh_checkboxes(lines: list[str]) -> tuple[list[str], int]:
178+
async def refresh_checkboxes(
179+
client: httpx2.AsyncClient, sem: asyncio.Semaphore, lines: list[str]
180+
) -> tuple[list[str], int]:
124181
"""Tick rows whose PR is now merged/closed. Returns (new_lines, n_updated)."""
182+
# Collect the PR numbers of every still-unchecked tracked row, then look
183+
# their states up concurrently.
184+
pending = [
185+
int(match.group("pr"))
186+
for line in lines
187+
if (match := ROW_RE.match(line)) and match.group("mark") != "x"
188+
]
189+
if pending:
190+
_log(f"Checking {len(pending)} open tracker row(s) for resolution...")
191+
states = dict(
192+
zip(
193+
pending,
194+
await asyncio.gather(*(pr_state(client, sem, n) for n in pending)),
195+
)
196+
)
197+
125198
updated = 0
126199
out: list[str] = []
127200
for line in lines:
128201
match = ROW_RE.match(line)
129202
if not match or match.group("mark") == "x":
130203
out.append(line)
131204
continue
132-
state = pr_state(int(match.group("pr")))
205+
state = states.get(int(match.group("pr")))
133206
if state is None:
134207
out.append(line)
135208
continue
@@ -138,10 +211,14 @@ def refresh_checkboxes(lines: list[str]) -> tuple[list[str], int]:
138211
return out, updated
139212

140213

141-
def build_stats_block() -> str:
142-
open_issues = _search_count(f"repo:{REPO} is:issue is:open")
143-
open_prs = _search_count(f"repo:{REPO} is:pr is:open")
144-
awaiting = _search_count(f'repo:{REPO} is:pr is:open label:"{AWAITING_LABEL}"')
214+
async def build_stats_block(client: httpx2.AsyncClient, sem: asyncio.Semaphore) -> str:
215+
_log("Collecting open issue/PR counts...")
216+
awaiting_query = f'repo:{REPO} is:pr is:open label:"{AWAITING_LABEL}"'
217+
open_issues, open_prs, awaiting = await asyncio.gather(
218+
_search_count(client, sem, f"repo:{REPO} is:issue is:open"),
219+
_search_count(client, sem, f"repo:{REPO} is:pr is:open"),
220+
_search_count(client, sem, awaiting_query),
221+
)
145222
today = dt.datetime.now(dt.UTC).date().isoformat()
146223

147224
lines = [
@@ -162,7 +239,7 @@ def build_stats_block() -> str:
162239
),
163240
"",
164241
]
165-
if top_dirs := top_awaiting_directories():
242+
if top_dirs := await top_awaiting_directories(client, sem):
166243
for rank, (directory, count) in enumerate(top_dirs, start=1):
167244
plural = "PR" if count == 1 else "PRs"
168245
lines.append(f"{rank}. `{directory}/` — {count} awaiting-reviews {plural}")
@@ -178,29 +255,39 @@ def splice_stats(text: str, stats_block: str) -> str:
178255
return f"{head}\n\n{stats_block}\n"
179256

180257

258+
async def compute(lines: list[str]) -> tuple[list[str], int, str]:
259+
"""Do all the network work: resolve tracker rows and build the stats block."""
260+
async with httpx2.AsyncClient(headers=_headers(), timeout=30) as client:
261+
sem = asyncio.Semaphore(CONCURRENCY)
262+
lines, n_updated = await refresh_checkboxes(client, sem, lines)
263+
stats_block = await build_stats_block(client, sem)
264+
return lines, n_updated, stats_block
265+
266+
181267
def main() -> int:
268+
started = time.monotonic()
182269
with open(TRACKER, encoding="utf-8") as handle:
183270
text = handle.read()
184271

185272
body_before_stats = text.split(STATS_HEADER, 1)[0]
186273
lines = body_before_stats.splitlines()
187-
lines, n_updated = refresh_checkboxes(lines)
188-
body = "\n".join(lines)
189274

190-
stats_block = build_stats_block()
275+
lines, n_updated, stats_block = asyncio.run(compute(lines))
276+
277+
body = "\n".join(lines)
191278
new_text = splice_stats(body, stats_block)
192279

193280
with open(TRACKER, "w", encoding="utf-8") as handle:
194281
handle.write(new_text)
195282

196-
print(f"Checked off {n_updated} newly-resolved pull request(s).")
283+
elapsed = time.monotonic() - started
284+
print(f"Checked off {n_updated} newly-resolved pull request(s) in {elapsed:.1f}s.")
197285

198286
today = dt.datetime.now(dt.UTC).date()
199287
if today >= HACKTOBERFEST_START:
200-
print(
288+
_log(
201289
f"Hacktoberfest 2026 has begun ({today} >= {HACKTOBERFEST_START}); "
202-
"the prep window is over — failing on purpose so this job is retired.",
203-
file=sys.stderr,
290+
"the prep window is over — failing on purpose so this job is retired."
204291
)
205292
return 1
206293
return 0

0 commit comments

Comments
 (0)