Skip to content

Commit 1eeed73

Browse files
ci: daily Hacktoberfest 2026 prep tracker refresh (cron 11:50 UTC) (#15225)
* 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. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * style: wrap implicit string concatenations (ISC004) * 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). --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 247cb93 commit 1eeed73

2 files changed

Lines changed: 258 additions & 0 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# Daily refresh of the Hacktoberfest 2026 open-PR cleanup tracker.
2+
# Ticks off any tracked pull request that has since been merged/closed, and
3+
# rewrites the "Automated statistics" section (open issue/PR counts + the top
4+
# three `awaiting reviews` directories). The job fails on purpose once
5+
# Hacktoberfest 2026 has begun (>= 2026-10-01), which is the signal to retire it.
6+
name: hacktoberfest_prep
7+
8+
on:
9+
schedule:
10+
- cron: "50 11 * * *" # 11:50 UTC every day
11+
workflow_dispatch: # allow a manual run while testing
12+
13+
permissions:
14+
contents: write
15+
16+
jobs:
17+
hacktoberfest-prep:
18+
# No point running on forks — this pushes to the repo's own docs file.
19+
if: github.repository == 'TheAlgorithms/Python'
20+
runs-on: ubuntu-latest
21+
steps:
22+
- uses: actions/checkout@v7
23+
- uses: actions/setup-python@v7
24+
with:
25+
python-version-file: .python-version
26+
allow-prereleases: true
27+
- name: Install dependencies
28+
run: python -m pip install --upgrade "httpx2>=2.0.1"
29+
- name: Update the tracker
30+
id: update
31+
env:
32+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
33+
GITHUB_REPOSITORY: ${{ github.repository }}
34+
# Don't let the intentional post-Oct-1 failure stop the commit step;
35+
# capture the exit code and re-raise it after pushing any changes.
36+
run: |
37+
set +e
38+
python scripts/hacktoberfest_prep_update.py
39+
echo "exit_code=$?" >> "$GITHUB_OUTPUT"
40+
- name: Commit any changes
41+
run: |
42+
git config --global user.name "$GITHUB_ACTOR"
43+
git config --global user.email "$GITHUB_ACTOR@users.noreply.github.com"
44+
git add docs/hacktober_2026_prep.md
45+
git commit -m "chore: refresh Hacktoberfest 2026 prep tracker" || echo "No changes to commit"
46+
git push || echo "Nothing to push"
47+
- name: Propagate the script's exit code
48+
run: exit ${{ steps.update.outputs.exit_code }}
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
#!/usr/bin/env python3
2+
"""Refresh the Hacktoberfest 2026 open-PR cleanup tracker.
3+
4+
This script is run once a day by the ``hacktoberfest_prep`` GitHub Actions
5+
workflow (see ``.github/workflows/hacktoberfest_prep.yml``). It:
6+
7+
1. Reads ``docs/hacktober_2026_prep.md`` and, for every tracked pull request
8+
that is still an unchecked ``[ ]`` box, checks whether the PR has since
9+
been merged or closed. Resolved rows are ticked (``[ ]`` -> ``[x]``) and
10+
annotated with ``merged`` / ``closed``.
11+
2. Rewrites a machine-generated ``## Automated statistics`` section at the end
12+
of the file with the current number of open issues and open pull requests
13+
and the top three algorithm directories that have the most open pull
14+
requests labelled ``awaiting reviews``.
15+
3. Exits non-zero once Hacktoberfest 2026 has begun (on or after
16+
2026-10-01, UTC), so the prep window closing is loud rather than silent.
17+
18+
It only uses the standard library and the ``GITHUB_TOKEN`` provided by the
19+
Actions runner, so there is nothing to install.
20+
"""
21+
22+
import datetime as dt
23+
import os
24+
import re
25+
import sys
26+
import time
27+
28+
import httpx2
29+
30+
REPO = os.environ.get("GITHUB_REPOSITORY", "TheAlgorithms/Python")
31+
TOKEN = os.environ.get("GITHUB_TOKEN", "")
32+
API = "https://api.github.com"
33+
TRACKER = "docs/hacktober_2026_prep.md"
34+
AWAITING_LABEL = "awaiting reviews"
35+
HACKTOBERFEST_START = dt.date(2026, 10, 1)
36+
37+
# A tracked row looks like: ``12. [ ] #15144 awaiting reviews``
38+
ROW_RE = re.compile(
39+
r"^(?P<idx>\d+)\.\s+\[(?P<mark>[ x])\]\s+#(?P<pr>\d+)\b(?P<rest>.*)$"
40+
)
41+
STATS_HEADER = "## Automated statistics"
42+
43+
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."""
46+
headers = {
47+
"Accept": "application/vnd.github+json",
48+
"X-GitHub-Api-Version": "2022-11-28",
49+
"User-Agent": "hacktoberfest-prep-bot",
50+
}
51+
if TOKEN:
52+
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)
70+
71+
72+
def _search_count(query: str) -> int:
73+
body, _ = _request(f"{API}/search/issues", {"q": query, "per_page": 1})
74+
return int(body.get("total_count", 0)) # type: ignore[union-attr]
75+
76+
77+
def pr_state(number: int) -> str | None:
78+
"""Return ``"merged"`` / ``"closed"`` for a resolved PR, else ``None``."""
79+
body, _ = _request(f"{API}/repos/{REPO}/pulls/{number}")
80+
if body.get("state") == "open": # type: ignore[union-attr]
81+
return None
82+
return "merged" if body.get("merged_at") else "closed" # type: ignore[union-attr]
83+
84+
85+
def top_awaiting_directories(
86+
limit: int = 3, max_prs: int = 400
87+
) -> list[tuple[str, int]]:
88+
"""Count open ``awaiting reviews`` PRs by the top-level directory they touch."""
89+
query = f'repo:{REPO} is:pr is:open label:"{AWAITING_LABEL}"'
90+
counts: dict[str, int] = {}
91+
page = 1
92+
scanned = 0
93+
while scanned < max_prs:
94+
body, _ = _request(
95+
f"{API}/search/issues",
96+
{"q": query, "per_page": 100, "page": page},
97+
)
98+
items = body.get("items", []) # type: ignore[union-attr]
99+
if not items:
100+
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
116+
if len(items) < 100:
117+
break
118+
page += 1
119+
ranked = sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))
120+
return ranked[:limit]
121+
122+
123+
def refresh_checkboxes(lines: list[str]) -> tuple[list[str], int]:
124+
"""Tick rows whose PR is now merged/closed. Returns (new_lines, n_updated)."""
125+
updated = 0
126+
out: list[str] = []
127+
for line in lines:
128+
match = ROW_RE.match(line)
129+
if not match or match.group("mark") == "x":
130+
out.append(line)
131+
continue
132+
state = pr_state(int(match.group("pr")))
133+
if state is None:
134+
out.append(line)
135+
continue
136+
out.append(f"{match.group('idx')}. [x] #{match.group('pr')} {state}")
137+
updated += 1
138+
return out, updated
139+
140+
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}"')
145+
today = dt.datetime.now(dt.UTC).date().isoformat()
146+
147+
lines = [
148+
STATS_HEADER,
149+
"",
150+
(
151+
f"_Generated automatically by "
152+
f"`scripts/hacktoberfest_prep_update.py` on {today} (UTC)._"
153+
),
154+
"",
155+
f"- **Open issues:** {open_issues}",
156+
f"- **Open pull requests:** {open_prs}",
157+
f"- **Open PRs labelled `{AWAITING_LABEL}`:** {awaiting}",
158+
"",
159+
(
160+
"**Top three directories to work on** (most open pull requests "
161+
f"labelled `{AWAITING_LABEL}`):"
162+
),
163+
"",
164+
]
165+
if top_dirs := top_awaiting_directories():
166+
for rank, (directory, count) in enumerate(top_dirs, start=1):
167+
plural = "PR" if count == 1 else "PRs"
168+
lines.append(f"{rank}. `{directory}/` — {count} awaiting-reviews {plural}")
169+
else:
170+
lines.append("_No open `awaiting reviews` pull requests found._")
171+
lines.append("")
172+
return "\n".join(lines)
173+
174+
175+
def splice_stats(text: str, stats_block: str) -> str:
176+
idx = text.find(STATS_HEADER)
177+
head = text[:idx].rstrip("\n") if idx != -1 else text.rstrip("\n")
178+
return f"{head}\n\n{stats_block}\n"
179+
180+
181+
def main() -> int:
182+
with open(TRACKER, encoding="utf-8") as handle:
183+
text = handle.read()
184+
185+
body_before_stats = text.split(STATS_HEADER, 1)[0]
186+
lines = body_before_stats.splitlines()
187+
lines, n_updated = refresh_checkboxes(lines)
188+
body = "\n".join(lines)
189+
190+
stats_block = build_stats_block()
191+
new_text = splice_stats(body, stats_block)
192+
193+
with open(TRACKER, "w", encoding="utf-8") as handle:
194+
handle.write(new_text)
195+
196+
print(f"Checked off {n_updated} newly-resolved pull request(s).")
197+
198+
today = dt.datetime.now(dt.UTC).date()
199+
if today >= HACKTOBERFEST_START:
200+
print(
201+
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,
204+
)
205+
return 1
206+
return 0
207+
208+
209+
if __name__ == "__main__":
210+
raise SystemExit(main())

0 commit comments

Comments
 (0)