From 90d55a9826e6342b0b8a45047851224b6f00c45a Mon Sep 17 00:00:00 2001 From: Priya Sundaram Date: Tue, 8 Sep 2026 06:14:06 +0000 Subject: [PATCH 1/4] ci: daily Hacktoberfest 2026 prep tracker refresh (cron 11:50 UTC) Adds .github/workflows/hacktoberfest_prep.yml (schedule: 50 11 * * *) and scripts/hacktoberfest_prep_update.py. Each run: - ticks tracked PR rows in docs/hacktober_2026_prep.md that are now merged/closed (`[ ]` -> `[x]`), - rewrites an 'Automated statistics' section with the current open issue and open PR counts plus the top three directories with the most open 'awaiting reviews' PRs, - exits non-zero once Hacktoberfest 2026 has begun (>= 2026-10-01), so the prep window closing is loud and the job gets retired. Standard library only; uses the Actions GITHUB_TOKEN. Refs #15081. --- .github/workflows/hacktoberfest_prep.yml | 46 +++++ scripts/hacktoberfest_prep_update.py | 213 +++++++++++++++++++++++ 2 files changed, 259 insertions(+) create mode 100644 .github/workflows/hacktoberfest_prep.yml create mode 100644 scripts/hacktoberfest_prep_update.py diff --git a/.github/workflows/hacktoberfest_prep.yml b/.github/workflows/hacktoberfest_prep.yml new file mode 100644 index 000000000000..4ed40378515d --- /dev/null +++ b/.github/workflows/hacktoberfest_prep.yml @@ -0,0 +1,46 @@ +# Daily refresh of the Hacktoberfest 2026 open-PR cleanup tracker. +# Ticks off any tracked pull request that has since been merged/closed, and +# rewrites the "Automated statistics" section (open issue/PR counts + the top +# three `awaiting reviews` directories). The job fails on purpose once +# Hacktoberfest 2026 has begun (>= 2026-10-01), which is the signal to retire it. +name: hacktoberfest_prep + +on: + schedule: + - cron: "50 11 * * *" # 11:50 UTC every day + workflow_dispatch: # allow a manual run while testing + +permissions: + contents: write + +jobs: + hacktoberfest-prep: + # No point running on forks — this pushes to the repo's own docs file. + if: github.repository == 'TheAlgorithms/Python' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 + with: + python-version-file: .python-version + allow-prereleases: true + - name: Update the tracker + id: update + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + # Don't let the intentional post-Oct-1 failure stop the commit step; + # capture the exit code and re-raise it after pushing any changes. + run: | + set +e + python scripts/hacktoberfest_prep_update.py + echo "exit_code=$?" >> "$GITHUB_OUTPUT" + - name: Commit any changes + run: | + git config --global user.name "$GITHUB_ACTOR" + git config --global user.email "$GITHUB_ACTOR@users.noreply.github.com" + git add docs/hacktober_2026_prep.md + git commit -m "chore: refresh Hacktoberfest 2026 prep tracker" || echo "No changes to commit" + git push || echo "Nothing to push" + - name: Propagate the script's exit code + run: exit ${{ steps.update.outputs.exit_code }} diff --git a/scripts/hacktoberfest_prep_update.py b/scripts/hacktoberfest_prep_update.py new file mode 100644 index 000000000000..8ff12a63176a --- /dev/null +++ b/scripts/hacktoberfest_prep_update.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python3 +"""Refresh the Hacktoberfest 2026 open-PR cleanup tracker. + +This script is run once a day by the ``hacktoberfest_prep`` GitHub Actions +workflow (see ``.github/workflows/hacktoberfest_prep.yml``). It: + +1. Reads ``docs/hacktober_2026_prep.md`` and, for every tracked pull request + that is still an unchecked ``[ ]`` box, checks whether the PR has since + been merged or closed. Resolved rows are ticked (``[ ]`` -> ``[x]``) and + annotated with ``merged`` / ``closed``. +2. Rewrites a machine-generated ``## Automated statistics`` section at the end + of the file with the current number of open issues and open pull requests + and the top three algorithm directories that have the most open pull + requests labelled ``awaiting reviews``. +3. Exits non-zero once Hacktoberfest 2026 has begun (on or after + 2026-10-01, UTC), so the prep window closing is loud rather than silent. + +It only uses the standard library and the ``GITHUB_TOKEN`` provided by the +Actions runner, so there is nothing to install. +""" + +from __future__ import annotations + +import datetime as dt +import json +import os +import re +import sys +import time +import urllib.error +import urllib.parse +import urllib.request + +REPO = os.environ.get("GITHUB_REPOSITORY", "TheAlgorithms/Python") +TOKEN = os.environ.get("GITHUB_TOKEN", "") +API = "https://api.github.com" +TRACKER = "docs/hacktober_2026_prep.md" +AWAITING_LABEL = "awaiting reviews" +HACKTOBERFEST_START = dt.date(2026, 10, 1) + +# A tracked row looks like: ``12. [ ] #15144 awaiting reviews`` +ROW_RE = re.compile( + r"^(?P\d+)\.\s+\[(?P[ x])\]\s+#(?P\d+)\b(?P.*)$" +) +STATS_HEADER = "## Automated statistics" + + +def _request(url: str) -> tuple[dict | list, dict]: + """GET ``url`` and return ``(json_body, headers)``, retrying on 403/rate limit.""" + headers = { + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "hacktoberfest-prep-bot", + } + if TOKEN: + headers["Authorization"] = f"Bearer {TOKEN}" + for attempt in range(4): + req = urllib.request.Request(url, headers=headers) # noqa: S310 + try: + with urllib.request.urlopen(req, timeout=30) as resp: # noqa: S310 + return json.load(resp), dict(resp.headers) + except urllib.error.HTTPError as exc: + remaining = exc.headers.get("X-RateLimit-Remaining") + if exc.code in (403, 429) and remaining == "0": + reset = int(exc.headers.get("X-RateLimit-Reset", "0")) + wait = max(1, reset - int(time.time())) + 1 + print(f"Rate limited; sleeping {wait}s", file=sys.stderr) + time.sleep(min(wait, 90)) + continue + if exc.code >= 500 and attempt < 3: + time.sleep(2 * (attempt + 1)) + continue + raise + msg = f"giving up on {url}" + raise RuntimeError(msg) + + +def _search_count(query: str) -> int: + url = f"{API}/search/issues?q={urllib.parse.quote(query)}&per_page=1" + body, _ = _request(url) + return int(body.get("total_count", 0)) # type: ignore[union-attr] + + +def pr_state(number: int) -> str | None: + """Return ``"merged"`` / ``"closed"`` for a resolved PR, else ``None``.""" + body, _ = _request(f"{API}/repos/{REPO}/pulls/{number}") + if body.get("state") == "open": # type: ignore[union-attr] + return None + return "merged" if body.get("merged_at") else "closed" # type: ignore[union-attr] + + +def top_awaiting_directories( + limit: int = 3, max_prs: int = 400 +) -> list[tuple[str, int]]: + """Count open ``awaiting reviews`` PRs by the top-level directory they touch.""" + query = f'repo:{REPO} is:pr is:open label:"{AWAITING_LABEL}"' + counts: dict[str, int] = {} + page = 1 + scanned = 0 + while scanned < max_prs: + url = ( + f"{API}/search/issues?q={urllib.parse.quote(query)}" + f"&per_page=100&page={page}" + ) + body, _ = _request(url) + items = body.get("items", []) # type: ignore[union-attr] + if not items: + break + for item in items: + number = item["number"] + files, _ = _request(f"{API}/repos/{REPO}/pulls/{number}/files?per_page=100") + dirs = set() + for changed in files: # type: ignore[union-attr] + parts = changed["filename"].split("/") + if len(parts) > 1 and not parts[0].startswith("."): + dirs.add(parts[0]) + for directory in dirs: + counts[directory] = counts.get(directory, 0) + 1 + scanned += 1 + if scanned >= max_prs: + break + if len(items) < 100: + break + page += 1 + ranked = sorted(counts.items(), key=lambda kv: (-kv[1], kv[0])) + return ranked[:limit] + + +def refresh_checkboxes(lines: list[str]) -> tuple[list[str], int]: + """Tick rows whose PR is now merged/closed. Returns (new_lines, n_updated).""" + updated = 0 + out: list[str] = [] + for line in lines: + match = ROW_RE.match(line) + if not match or match.group("mark") == "x": + out.append(line) + continue + state = pr_state(int(match.group("pr"))) + if state is None: + out.append(line) + continue + out.append(f"{match.group('idx')}. [x] #{match.group('pr')} {state}") + updated += 1 + return out, updated + + +def build_stats_block() -> str: + open_issues = _search_count(f"repo:{REPO} is:issue is:open") + open_prs = _search_count(f"repo:{REPO} is:pr is:open") + awaiting = _search_count(f'repo:{REPO} is:pr is:open label:"{AWAITING_LABEL}"') + today = dt.datetime.now(dt.UTC).date().isoformat() + top_dirs = top_awaiting_directories() + + lines = [ + STATS_HEADER, + "", + f"_Generated automatically by " + f"`scripts/hacktoberfest_prep_update.py` on {today} (UTC)._", + "", + f"- **Open issues:** {open_issues}", + f"- **Open pull requests:** {open_prs}", + f"- **Open PRs labelled `{AWAITING_LABEL}`:** {awaiting}", + "", + "**Top three directories to work on** (most open pull requests labelled " + f"`{AWAITING_LABEL}`):", + "", + ] + if top_dirs: + for rank, (directory, count) in enumerate(top_dirs, start=1): + plural = "PR" if count == 1 else "PRs" + lines.append(f"{rank}. `{directory}/` — {count} awaiting-reviews {plural}") + else: + lines.append("_No open `awaiting reviews` pull requests found._") + lines.append("") + return "\n".join(lines) + + +def splice_stats(text: str, stats_block: str) -> str: + idx = text.find(STATS_HEADER) + head = text[:idx].rstrip("\n") if idx != -1 else text.rstrip("\n") + return f"{head}\n\n{stats_block}\n" + + +def main() -> int: + with open(TRACKER, encoding="utf-8") as handle: + text = handle.read() + + body_before_stats = text.split(STATS_HEADER, 1)[0] + lines = body_before_stats.splitlines() + lines, n_updated = refresh_checkboxes(lines) + body = "\n".join(lines) + + stats_block = build_stats_block() + new_text = splice_stats(body, stats_block) + + with open(TRACKER, "w", encoding="utf-8") as handle: + handle.write(new_text) + + print(f"Checked off {n_updated} newly-resolved pull request(s).") + + today = dt.datetime.now(dt.UTC).date() + if today >= HACKTOBERFEST_START: + print( + f"Hacktoberfest 2026 has begun ({today} >= {HACKTOBERFEST_START}); " + "the prep window is over — failing on purpose so this job is retired.", + file=sys.stderr, + ) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 95d0c1ded02032783532f8503b9a64c038f40b01 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 06:14:42 +0000 Subject: [PATCH 2/4] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- scripts/hacktoberfest_prep_update.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/scripts/hacktoberfest_prep_update.py b/scripts/hacktoberfest_prep_update.py index 8ff12a63176a..2bb277bf74b0 100644 --- a/scripts/hacktoberfest_prep_update.py +++ b/scripts/hacktoberfest_prep_update.py @@ -149,7 +149,6 @@ def build_stats_block() -> str: open_prs = _search_count(f"repo:{REPO} is:pr is:open") awaiting = _search_count(f'repo:{REPO} is:pr is:open label:"{AWAITING_LABEL}"') today = dt.datetime.now(dt.UTC).date().isoformat() - top_dirs = top_awaiting_directories() lines = [ STATS_HEADER, @@ -165,7 +164,7 @@ def build_stats_block() -> str: f"`{AWAITING_LABEL}`):", "", ] - if top_dirs: + if top_dirs := top_awaiting_directories(): for rank, (directory, count) in enumerate(top_dirs, start=1): plural = "PR" if count == 1 else "PRs" lines.append(f"{rank}. `{directory}/` — {count} awaiting-reviews {plural}") From 3d3627a053c25aeedb5f8dde11b0c949fba7aa86 Mon Sep 17 00:00:00 2001 From: Priya Sundaram Date: Tue, 8 Sep 2026 06:18:16 +0000 Subject: [PATCH 3/4] style: wrap implicit string concatenations (ISC004) --- scripts/hacktoberfest_prep_update.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/scripts/hacktoberfest_prep_update.py b/scripts/hacktoberfest_prep_update.py index 2bb277bf74b0..1ec44ef59b0d 100644 --- a/scripts/hacktoberfest_prep_update.py +++ b/scripts/hacktoberfest_prep_update.py @@ -153,15 +153,19 @@ def build_stats_block() -> str: lines = [ STATS_HEADER, "", - f"_Generated automatically by " - f"`scripts/hacktoberfest_prep_update.py` on {today} (UTC)._", + ( + f"_Generated automatically by " + f"`scripts/hacktoberfest_prep_update.py` on {today} (UTC)._" + ), "", f"- **Open issues:** {open_issues}", f"- **Open pull requests:** {open_prs}", f"- **Open PRs labelled `{AWAITING_LABEL}`:** {awaiting}", "", - "**Top three directories to work on** (most open pull requests labelled " - f"`{AWAITING_LABEL}`):", + ( + "**Top three directories to work on** (most open pull requests " + f"labelled `{AWAITING_LABEL}`):" + ), "", ] if top_dirs := top_awaiting_directories(): From 9a540d3f243e84786c38123f836e9ea9fc480cf0 Mon Sep 17 00:00:00 2001 From: Priya Sundaram Date: Tue, 8 Sep 2026 06:45:43 +0000 Subject: [PATCH 4/4] refactor: use httpx2 for API calls, drop unneeded future import Address review feedback on the Hacktoberfest prep cron: - Switch the tracker script from urllib to httpx2, the repo's standard HTTP client, and add an install step to the workflow. - Drop 'from __future__ import annotations' (unnecessary on Python >= 3.14t). --- .github/workflows/hacktoberfest_prep.yml | 2 + scripts/hacktoberfest_prep_update.py | 54 +++++++++++------------- 2 files changed, 26 insertions(+), 30 deletions(-) diff --git a/.github/workflows/hacktoberfest_prep.yml b/.github/workflows/hacktoberfest_prep.yml index 4ed40378515d..c8a7a76a00df 100644 --- a/.github/workflows/hacktoberfest_prep.yml +++ b/.github/workflows/hacktoberfest_prep.yml @@ -24,6 +24,8 @@ jobs: with: python-version-file: .python-version allow-prereleases: true + - name: Install dependencies + run: python -m pip install --upgrade "httpx2>=2.0.1" - name: Update the tracker id: update env: diff --git a/scripts/hacktoberfest_prep_update.py b/scripts/hacktoberfest_prep_update.py index 1ec44ef59b0d..deaa67db0341 100644 --- a/scripts/hacktoberfest_prep_update.py +++ b/scripts/hacktoberfest_prep_update.py @@ -19,17 +19,13 @@ Actions runner, so there is nothing to install. """ -from __future__ import annotations - import datetime as dt -import json import os import re import sys import time -import urllib.error -import urllib.parse -import urllib.request + +import httpx2 REPO = os.environ.get("GITHUB_REPOSITORY", "TheAlgorithms/Python") TOKEN = os.environ.get("GITHUB_TOKEN", "") @@ -45,7 +41,7 @@ STATS_HEADER = "## Automated statistics" -def _request(url: str) -> tuple[dict | list, dict]: +def _request(url: str, params: dict | None = None) -> tuple[dict | list, dict]: """GET ``url`` and return ``(json_body, headers)``, retrying on 403/rate limit.""" headers = { "Accept": "application/vnd.github+json", @@ -55,29 +51,26 @@ def _request(url: str) -> tuple[dict | list, dict]: if TOKEN: headers["Authorization"] = f"Bearer {TOKEN}" for attempt in range(4): - req = urllib.request.Request(url, headers=headers) # noqa: S310 - try: - with urllib.request.urlopen(req, timeout=30) as resp: # noqa: S310 - return json.load(resp), dict(resp.headers) - except urllib.error.HTTPError as exc: - remaining = exc.headers.get("X-RateLimit-Remaining") - if exc.code in (403, 429) and remaining == "0": - reset = int(exc.headers.get("X-RateLimit-Reset", "0")) - wait = max(1, reset - int(time.time())) + 1 - print(f"Rate limited; sleeping {wait}s", file=sys.stderr) - time.sleep(min(wait, 90)) - continue - if exc.code >= 500 and attempt < 3: - time.sleep(2 * (attempt + 1)) - continue - raise + resp = httpx2.get(url, params=params, headers=headers, timeout=30) + if resp.is_success: + return resp.json(), dict(resp.headers) + remaining = resp.headers.get("X-RateLimit-Remaining") + if resp.status_code in (403, 429) and remaining == "0": + reset = int(resp.headers.get("X-RateLimit-Reset", "0")) + wait = max(1, reset - int(time.time())) + 1 + print(f"Rate limited; sleeping {wait}s", file=sys.stderr) + time.sleep(min(wait, 90)) + continue + if resp.status_code >= 500 and attempt < 3: + time.sleep(2 * (attempt + 1)) + continue + resp.raise_for_status() msg = f"giving up on {url}" raise RuntimeError(msg) def _search_count(query: str) -> int: - url = f"{API}/search/issues?q={urllib.parse.quote(query)}&per_page=1" - body, _ = _request(url) + body, _ = _request(f"{API}/search/issues", {"q": query, "per_page": 1}) return int(body.get("total_count", 0)) # type: ignore[union-attr] @@ -98,17 +91,18 @@ def top_awaiting_directories( page = 1 scanned = 0 while scanned < max_prs: - url = ( - f"{API}/search/issues?q={urllib.parse.quote(query)}" - f"&per_page=100&page={page}" + body, _ = _request( + f"{API}/search/issues", + {"q": query, "per_page": 100, "page": page}, ) - body, _ = _request(url) items = body.get("items", []) # type: ignore[union-attr] if not items: break for item in items: number = item["number"] - files, _ = _request(f"{API}/repos/{REPO}/pulls/{number}/files?per_page=100") + files, _ = _request( + f"{API}/repos/{REPO}/pulls/{number}/files", {"per_page": 100} + ) dirs = set() for changed in files: # type: ignore[union-attr] parts = changed["filename"].split("/")