15153. 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
2231import datetime as dt
2332import os
2433import re
3443AWAITING_LABEL = "awaiting reviews"
3544HACKTOBERFEST_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``
3852ROW_RE = re .compile (
3953 r"^(?P<idx>\d+)\.\s+\[(?P<mark>[ x])\]\s+#(?P<pr>\d+)\b(?P<rest>.*)$"
4054)
4155STATS_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+
181267def 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