Imported from rabbive/contrib-scout (
SKILL.md). Install upstream withnpx skills add rabbive/contrib-scout. Copyright stays with the author.
Should I Contribute? — repository contribution research
Research ANY open-source repository and produce a detailed analysis report: what it is, how healthy it is, how it actually treats outside contributors, what a PR/issue from a stranger would experience — and, at the end, a clear GO / NO-GO / GO-IF verdict on whether YOU should contribute. The core insight: PR acceptance is a much better signal of open-source health than PR volume — a project can look busy while quietly ignoring everyone outside the core team.
Output contract
Deliver ONE markdown report with these sections (see Report template at the end):
- Executive summary — 3-6 bullet verdict (GO / GO-IF / NO-GO) with the headline numbers
- Snapshot — identity, stats, license, stack
- Momentum — is the project alive and shipping?
- Contribution landscape — the core: open-PR census, age buckets, outside-author share, merge rates, latency, substance, first-timer outcomes
- Available work — unclaimed newcomer issues; is there anything for the requester to actually DO?
- Issue health — open issues, stale bugs, "documented bug with unmerged fix" search
- Governance & maintainability — docs, templates, who actually merges, bus factor
- Community sentiment — HN/Reddit/Discord signals, maintainer quotes
- Event fitness — Hacktoberfest / GSoC suitability, only when the requester mentions one
- Risks & opportunities for the requester
- Methodology & caveats — every number gets its source and sample
Every claim MUST be grounded in a number you computed or a quote you read. No vibes.
Scripts — use these, do not retype them
scripts/ holds the tested implementations. They handle search-rate pacing, 403 retry,
checkpoint/resume, hour-level slice splitting, bot exclusion, and UTC windows — all of
which are easy to get wrong by hand and expensive to get wrong mid-run. They work with
gh if installed, else curl + $GITHUB_TOKEN, else unauthenticated.
| Script | Answers |
|---|---|
scripts/screen.py |
many repos at once — which of N candidates deserve a deep dive (8 search + 1 core each) |
scripts/pr_census.py |
merge rates (overall/outside/core), time-to-merge and time-to-first-response percentiles, diff-size substance, true first-timer cohort |
scripts/issue_inventory.py |
how many newcomer issues are genuinely unclaimed right now |
scripts/hacktoberfest_fit.py |
topic/label opt-in, October seasonality, anti-Hacktoberfest language |
scripts/gsoc_orgs.py |
official GSoC org list per year, participation history, ideas links (no GitHub API) |
scripts/ghapi.py |
shared transport (imported, not run) |
Run --help on any of them. Each prints JSON to stdout and progress to stderr.
Prefer them over hand-rolled loops; fall back to the raw gh recipes below only when
a script cannot run.
When the request is "find me repos" rather than "assess this repo", work funnel-shaped — a full deep dive costs minutes per repo, so do not run it on a long list:
# 1. build a candidate list from a real source, not memory
python3 scripts/gsoc_orgs.py --year 2026 --tech python --github --repos-out cands.txt
# 2. cheap screen across all of them
python3 scripts/screen.py --file cands.txt --probe-labels --out screen.json
# 3. deep dive ONLY the survivors
python3 scripts/pr_census.py <tier-1-repo> --days 30
python3 scripts/issue_inventory.py <tier-1-repo>
screen.py's merge rate is all authors including bots — a triage signal, never quote
it as the outside-contributor rate. Only pr_census.py produces that.
Setup
Target and API access (GitHub, the common case)
-
ALWAYS set the target explicitly first:
REPO="owner/name"(e.g.REPO="anomalyco/opencode"). Use$REPOin every request. Do NOT rely on{owner}/{repo}placeholders — gh only expands those inside a cloned repo or with-R. -
Check auth first, and say which mode you ran in — it determines every sample size below:
command -v gh >/dev/null && gh auth status 2>&1 | head -3 curl -s https://api.github.com/rate_limit | jq '{core: .resources.core, search: .resources.search}' -
Core and search are metered SEPARATELY — the single most common budgeting mistake here:
core search Authenticated ( gh auth loginor$GITHUB_TOKEN)5,000/hr 30/min Unauthenticated 60/hr 10/min The day-sliced merge-rate census is all search, so the 5,000/hr figure never applies to it. A 90-day census is ≥3 min authenticated, ≥9 min unauthenticated, floor, from pacing alone. If
ghis absent, a bareGITHUB_TOKENwith no scopes still buys the authenticated limits — worth asking the user for one before shrinking the window. -
The
scripts/helpers auto-selectgh→curl+$GITHUB_TOKEN→ unauthenticated, and pace/retry accordingly. For ad-hoc calls:gh api "repos/$REPO/...", orcurl -H "Authorization: Bearer $GITHUB_TOKEN" "https://api.github.com/repos/$REPO/...". -
On 403, authenticate or shrink the window — never fabricate numbers.
-
Always request
per_page=100(max). Paginate via theLink: rel="next"header or&page=N. Stop when a page returns fewer thanper_pageitems. -
Renamed/transferred repos: if a search query 422s with "cannot be searched", the repo was likely renamed — GitHub search doesn't index redirect targets. Resolve the canonical name first:
gh api "repos/$REPO" --jq .full_name(e.g.sst/opencode→anomalyco/opencode) and redo all queries with it.
Non-GitHub hosts
- GitLab:
https://gitlab.com/api/v4/projects/<url-encoded-path>— mirrors:merge_requests?state=opened&per_page=100,issues,releases; MR merged state:state=merged; author identity viaauthor.usernamevsmembers/all(auth required). - Self-hosted / no API: shallow clone (
git clone --depth=1) and analyzegit log— you lose PR-level data; say so in the report.
Phase 1 — Snapshot
REPO="owner/name"
gh api "repos/$REPO" --jq '{name, description, language, license: (.license.spdx_id // null), stars: .stargazers_count, forks: .forks_count, open_issues: .open_issues_count, archived, pushed_at, created_at, homepage, topics, default_branch, visibility}'
gh api "repos/$REPO/languages" --jq 'to_entries | sort_by(-.value) | .[:6]'
gh api "repos/$REPO/community/profile" --jq '{health_percentage, files: (.files | with_entries(.value = (.value | type == "object"))) }'
Community profile is gold: it reveals whether CONTRIBUTING.md, CODE_OF_CONDUCT, issue/PR templates, README exist — the on-ramp for outsiders. Note pushed_at (last commit to default branch).
Read README.md (top ~80 lines) and CONTRIBUTING.md if present — verbatim quotes from these go in the report. Extract: what is it, who is it for, install/usage, contribution workflow promised vs actual (later phases measure the gap).
Phase 2 — Momentum
# 52 weeks of commit counts
gh api "repos/$REPO/stats/commit_activity" --jq '[.[] | .total] | {sum: add, weeks_with_commits: (map(select(. > 0)) | length), last_8_weeks: .[-8:]}'
# Releases (cadence + recency)
gh api "repos/$REPO/releases?per_page=30" --jq '.[] | {tag_name, published_at}'
# Contributor distribution (bus factor: how concentrated is the work?)
# PAGINATE ALL PAGES (per_page=100, page 1..N until empty) — page 1 alone is NOT the contributor base.
gh api "repos/$REPO/contributors?per_page=100&page=1"
# then accumulate; compute: {total: length, top5, top1_share: .[0].contributions / (map(.contributions)|add) * 100}
# Budget fallback: if >10 pages and rate-limited, label the stat "of the first N contributors" — never present as total.
Interpretation:
- 6+ months of commits every week + releases in the last 3 months → alive. Watchdog: release gap > 6 months on a popular repo = zombie or big-bang rewrite incoming.
- top-1 contributor > 40% of commits → bus-factor 1; contributions carry personal-review risk.
- Read the last 3 release notes briefly (breaking changes, direction).
Phase 3 — Contribution landscape (THE core)
Compute ALL of these — they are the heart of the report.
3a. Open-PR census
# All open PRs: paginate per_page=100, page=1,2,... until an empty page; accumulate into one JSON array.
gh api "repos/$REPO/pulls?state=open&per_page=100&page=1"
# Cross-check the accumulated total against search (exact total_count):
gh api "search/issues?q=repo:$REPO+is:pr+is:open&per_page=1" --jq '.total_count'
# Fields per PR: number, title, author_association, created_at, user.login, draft, merged_at (null), labels
Compute, from the FULL open set:
- total open PRs
- outside-core share: count
author_associationinNONE | FIRST_TIMER | FIRST_TIME_CONTRIBUTOR | CONTRIBUTORvsMEMBER | OWNER | COLLABORATOR - age buckets: open longer than 30 days, 90 days, 180 days (from
created_at) - stale queue: open PRs with no maintainer activity in 30d (approximation: no timeline/review comments; if too costly, report age buckets only and say so)
- draft PR share
3b. Closed-PR merge rate (exact, sampled on CLOSED-AT)
WARNING: pulls?state=closed sorts by created_at (desc), not closed_at — fetching its pages gives "most recently CREATED closed PRs", NOT "last closed". Never call that sample "last N closed".
EXACT method — partition the window into day slices, fetch EVERY result in each slice, dedupe, aggregate. Ordering becomes irrelevant and the 1,000-result search cap is avoided.
Run the script. It implements the slicing plus everything that makes it survive contact with a real repo:
python3 scripts/pr_census.py "$REPO" --days 90 --out census.json
python3 scripts/pr_census.py "$REPO" --days 14 # unauthenticated budget
python3 scripts/pr_census.py "$REPO" --start 2025-10-01 --end 2025-11-01 # one October
python3 scripts/pr_census.py "$REPO" --days 30 --verify-firsttimers # exact cohort
It writes {"report": {...}, "items": [...]} — items is the deduped population that
Phases 3d and 5 reuse, so pass --out and keep it.
What it handles that hand-rolled loops do not:
- Search pacing. Search is metered separately at 30 req/min authenticated (10/min unauthenticated) — NOT the 5,000/hr core budget. A 90-day census is 90+ search calls, so it must pace or it will 403 partway.
- Retry + checkpoint. Rate-limit errors sleep and retry honoring
Retry-After/x-ratelimit-reset; every slice is checkpointed, so an interrupted run resumes instead of re-spending the budget. - Slice splitting. A day with >1,000 closes subdivides into 6-hour then 1-hour blocks. High-traffic repos otherwise silently truncate.
- UTC windows.
closed:bounds are UTC; a local-time "today" shifts the newest slice into the future from any non-UTC timezone. - Tolerance, not assert. The fetched-vs-aggregate invariant is a warning with a ±1% default tolerance. The aggregate is queried after the slices, so anything closed during a multi-minute run appears in one and not the other, and reopened-then-reclosed PRs move their
closed_at. A hard assert false-alarms on any busy repo. Report the drift in Methodology; investigate only above tolerance. - Bot exclusion, actually applied. Reports
rates_humans_only(excludesuser.type == "Bot"and logins ending[bot]) alongsiderates_including_bots. Quote the humans-only figure. This is not cosmetic: oncli/cliit moved a measured outside merge rate from 40% to 50%.
Bash-only alternative for the per-day iteration (GNU date): NEXT=$(date -d "$D +1 day" +%F) — never string-concatenate dates. Also fetch the exact aggregate counts and require them to match your accumulation (same invariant; define the window FIRST). NOTE: closed:A..B with day-only bounds is INCLUSIVE of both full days (spans B-A+1 days) — use explicit T00:00:00Z hour bounds for half-open windows:
REPO="owner/name"; START=$(date -d "90 days ago" +%Y-%m-%d); END=$(date -d yesterday +%Y-%m-%d)
# per-day slice: closed:${D}T00:00:00Z..$(date -d "$D +1 day" +%Y-%m-%d)T00:00:00Z
# aggregates over the same half-open [START 00:00Z, today 00:00Z) window:
gh api "search/issues?q=repo:$REPO+is:pr+is:closed+closed:${START}T00:00:00Z..$(date -d "$END +1 day" +%Y-%m-%d)T00:00:00Z&per_page=1" --jq '.total_count'
gh api "search/issues?q=repo:$REPO+is:pr+is:merged+closed:${START}T00:00:00Z..$(date -d "$END +1 day" +%Y-%m-%d)T00:00:00Z&per_page=1" --jq '.total_count'
Compute (state window and accumulated n, e.g. "all 262 PRs closed 2026-08-04..08-10"):
- merged vs closed-unmerged counts (cross-check with the two
total_counts above) - overall merge rate = merged / sample
- outside-author merge rate: merged where author_association is outside (NONE/CONTRIBUTOR/FIRST_*)
- core-author merge rate for contrast (MEMBER/OWNER/COLLABORATOR)
- first-timer outcomes: of closed-unmerged, how many were FIRST_TIME_CONTRIBUTOR/FIRST_TIMER? Of merged, how many?
- time-window drill-down (monthly trick): last fully-elapsed month, merged count and outside merges that month. Derive the month dynamically — never hardcode it:
LAST_MONTH=$(date -d "$(date +%Y-%m-01) -1 day" +%Y-%m) # last completed month (GNU date; macOS: date -v1d -v-1d +%Y-%m)
START="${LAST_MONTH}-01"; END=$(date -d "${LAST_MONTH}-01 +1 month -1 day" +%Y-%m-%d)
gh api "search/issues?q=repo:$REPO+is:pr+is:merged+merged:${START}..${END}&per_page=1" --jq '.total_count'
gh api "search/issues?q=repo:$REPO+is:pr+is:closed+closed:${START}..${END}&per_page=1" --jq '.total_count'
These totals alone cannot split outside vs core merges — the split needs per-item data, so run the 3b day-slice loop over the month window (or merged:${START}..${END} items) and classify by author_association. State in Methodology which you did.
Budget reality check: full 90-day chunking ≈ 90+ search requests. Search is capped at 30/min authenticated, 10/min unauthenticated — the 5,000/hr figure is the core limit and does NOT apply here. So a 90-day census takes ≥3 minutes authenticated and ≥9 minutes unauthenticated no matter how fast your machine is; the script paces itself and prints the estimate up front. With no token, shrink to --days 14 and LABEL it: "exact for the last N days only". Only if rate limits make even that impossible, fall back to pulls?state=closed pages labeled "most recently CREATED closed PRs — created-order approximation, NOT last closed".
Never sample with sort=updated / sort=created on search — ordering there is relevance/activity-based and is empirically unusable for rates (measured: 0/100 merged by updated desc in a window whose true rate was 41%).
3c. Author-association decoding
GitHub's author_association field on PRs/issues:
| Value | Meaning |
|---|---|
| OWNER | repo owner |
| MEMBER | org member / core team |
| COLLABORATOR | explicitly added collaborator |
| CONTRIBUTOR | has a prior merged PR |
| FIRST_TIME_CONTRIBUTOR | first PR on the repo (has committed before) |
| FIRST_TIMER | very first GitHub interaction |
| NONE | never engaged |
Outside/core split for the report: outside = NONE + FIRST_TIMER + FIRST_TIME_CONTRIBUTOR + CONTRIBUTOR; core = MEMBER + OWNER + COLLABORATOR. State the split in the report's Methodology. Caveat: COLLABORATOR can include invited community members — check repo collaborators when the count is material.
CRITICAL:
author_associationis computed at READ time, not at PR time. GitHub evaluates it against current state every time you fetch. Two consequences that invalidate naive historical analysis:
- Success erases the first-timer. The moment a newcomer's PR merges they become
CONTRIBUTOR. First-timers who were rejected stayFIRST_TIME_CONTRIBUTORforever. So a raw first-timer merge rate is biased toward zero — the metric drifts in exactly the direction that manufactures a NO-GO verdict.- Departed maintainers read as outsiders. Someone who left the org drops from
MEMBERtoCONTRIBUTOR, corrupting the core/outside split for old windows.Measured: of 100 merged PRs from H1 2020 in
cli/cli, zero carry anyFIRST_TIME*value and zero carryMEMBER— 98CONTRIBUTOR, 2NONE— though that repo certainly had both first-timers and core-team merges then.The field is trustworthy for windows of the last few days. For anything older, use
pr_census.py --verify-firsttimers, which reconstructs the real cohort by asking whether each author had a merged PR in the repo before their PR was opened. Compare itstrue_first_timersagainstraw_association_firsttimers— the gap is the bias.Also:
author_association: NONEon a PR is semantically odd (a PR author has engaged by definition) and skews toward bots and unresolvable accounts. Before letting a "NONE: 0% merged" figure drive a verdict, open 5 of those PRs and confirm they are real humans making real attempts.
3d. The first-timer funnel
The most damning number: of PRs closed WITHOUT merging, how many were first-time contributors.
GitHub search does NOT support author_association: as a qualifier (it 422s), but search result items carry author_association — so compute the funnel from the slice-accumulated items of 3b (deduped by number, classified via .pull_request.merged_at). The 3b Python already prints this cross-tab; if you accumulated items in a JSON file instead, aggregate with:
# items.json = accumulated 3b items. Merged status MUST come from .pull_request.merged_at (top-level is null in search results).
jq -r 'group_by(.author_association)[] | "\(.[0].author_association): total=\(length) merged=\(map(select(.pull_request.merged_at != null)) | length)"' items.json
If you need a bigger funnel population, extend the window with more day slices (3b step 2).
Report: "of N closed-unmerged PRs in the window, M were by first-timers (FIRST_TIMER/FIRST_TIME_CONTRIBUTOR); of the merged ones, K were first-timers." Label this cohort "as-of-read-date" unless you ran --verify-firsttimers — see the 3c warning; the raw numbers understate first-timer success. Also check the 20 most recently MERGED PRs' authors (from the 3b items, sort by .pull_request.merged_at descending — no other ordering is valid): any outside names repeated? Repetition = pipeline actually works for newcomers.
3e. Latency — will the merge land in time?
A merge rate with no clock attached is half a metric. A repo that merges 60% of outside
PRs on a 90-day median is useless inside a 31-day event window, and a repo that takes six
weeks to first-comment cannot produce the mentor back-and-forth GSoC selection requires.
pr_census.py computes this from the same population (no extra search cost for
time-to-merge; ~2 core requests per sampled PR for first-response):
- time-to-merge p50/p75/p90 for outside authors, plus
fits_31_day_window_pct - time-to-first-human-response p50/p75/p90 — earliest non-author, non-bot comment or review
silent_pct— share of sampled outside PRs that nobody replied to at all
Interpretation: first-response p50 > 14 days means plan on being ignored; silent_pct
over ~40% means the queue is decorative. Report latency next to the merge rate, always —
they are one finding, not two.
3f. Substance — what KIND of outside PR merges?
For a portfolio, one merged 300-line feature outweighs twenty typo fixes; for GSoC, a
mentor wants evidence you can move real code. pr_census.py samples merged outside PRs
and reports churn_lines p50/p90, docs_only_pct (from file paths), and
substantial_pct (≥50 lines changed).
A repo where outside merges are ~100% docs-only is still a valid first contribution target, but say so plainly: it will not by itself demonstrate engineering depth.
Phase 4 — Issue health
# Exact open-issue total (search total_count is exact; issue items are capped at 1000):
gh api "search/issues?q=repo:$REPO+is:issue+is:open&per_page=1" --jq '.total_count'
# Census items: paginate ALL open pages (issues endpoint mixes in PRs — filter .pull_request == null)
gh api "repos/$REPO/issues?state=open&per_page=100&page=1" # pages 1..N until empty; accumulate
# then compute: count, with_bug_label, oldest bug-labeled items
- total open issues (note: repo metadata
open_issues_countincludes PRs — use the search total_count above, or your accumulated census) - bug-label backlog and its age: these are the "documented bugs with unmerged PRs sitting that fix them" claims. Do NOT assume a label literally named "bug" — discover the repo's bug-like label(s) first (
gh api "repos/$REPO/labels?per_page=100"and pick e.g.bug,kind/bug,:bug:), then for the top 5 oldest bug-labeled issues find PRs that actually reference them. A baresearch/issues?q=<number>hit is FULL-TEXT — it proves nothing about linkage; the reliable source is the issue timeline'scross-referencedevents (a PR that mentions the issue shows up assource.issuewithpull_requestset):
A closed-but-unmerged PR with a body closing-ref is exactly the finding you want; an open one is a "sitting fix PR" — both open AND closed states matter. If the timeline returns nothing (issue closed by direct commit), fall back togh api -H "Accept: application/vnd.github.mockingbird-preview+json" \ "repos/$REPO/issues/$N/timeline?per_page=100" \ --jq '[.[] | select(.event == "cross-referenced" and .source.issue.pull_request != null) | .source.issue.number][]' \ | while read -r n; do gh api "repos/$REPO/pulls/$n" --jq \ '{number, state, merged: (.merged == true), closes_issue: ((.body // "") | test("(?i)(fix(es|ed)?|clos(es|ed)?|resolv(es|ed)?)\\s+#'"$N"'"))}' donesearch/issues?q=repo:$REPO+is:pr+<issue_number>as CANDIDATES ONLY, and verify each PR body the same way before claiming linkage. State which label(s) you used in Methodology. - response proxy (cheap): of the last 30 open issues, how many have a non-bot maintainer comment? (Use
issues/{n}/timelineonly on a small sample — expensive.) - stale-bot presence: does the repo run a stale-issue bot? (search issues for the
stalelabel, or check.github/workflows). A stale bot with a short window explains low open counts — normalize for it.
Phase 4b — Available work (is there anything for the requester to DO?)
Merge rate says the pipeline works. It does not say the queue has anything left in it. A repo can score a healthy outside merge rate while every newcomer issue is assigned or already has a PR sitting on it — in which case the honest answer is "great project, nothing for you today".
python3 scripts/issue_inventory.py "$REPO"
python3 scripts/issue_inventory.py "$REPO" --labels "good first issue,help wanted" --check-prs 40
It discovers newcomer-ish labels rather than assuming one named good first issue
(repos use E-easy, first-timers-only, up-for-grabs, beginner, …), then for the
newest unassigned issues verifies availability via the timeline's cross-referenced
events. Reports available_now, blocked_by_open_pr, assigned_pct, and a ranked
top_candidates list with URLs.
Three states, and the distinction matters — never present unverified as available:
pr_check |
meaning |
|---|---|
clean |
unassigned, no open PR found — genuinely pickable |
taken |
an open PR already references it; picking it means a collision |
unknown |
timeline lookup failed; availability NOT established |
Measured examples of why this phase exists: appwrite/appwrite has a 76% October merge
rate and exactly 1 open good first issue — already assigned, so available_now: 0.
pola-rs/polars had 3 unassigned good-first-issues of which 2 already had open PRs.
Merge-rate-only analysis calls both a GO.
Put the surviving top_candidates URLs in the report. A verdict the requester can act on
beats a verdict they have to go re-research.
Phase 5 — Governance & maintainability
- Read
.github/contents: CONTRIBUTING.md (promised workflow), issue/PR templates, CODEOWNERS (who reviews what), FUNDING.yml, CI workflows (do they run on PRs from forks? look for thepull_requesttrigger). - Who actually merges: search result items do NOT carry
merged_by— fetch it per PR from a capped sample of the 30 most recently merged items of the 3b accumulation:
One person merging everything = single-gate risk. Bot merges (dependabot/renovate) inflate merge rates — EXCLUDEjq -r '[.[] | select(.pull_request.merged_at != null)] | sort_by(.pull_request.merged_at) | reverse | .[:30][] | .number' items.json | while read -r n; do gh api "repos/$REPO/pulls/$n" --jq '.merged_by.login' done | sort | uniq -c | sort -rnuser.type == "Bot"from outside-contributor stats and note it. (Do not usepulls?state=closedpages for this — they are created-ordered.) - License check: OSI-approved (MIT/Apache/GPL...) vs source-available (SSPL/ELv2/BUSL/"fair use") — "open source only in name" has a license dimension. Read the LICENSE file if non-standard.
- Openness signals: newcomer labels (
good first issue,help wanted) with recent PRs by new authors; maintainer response to contribution questions; any "we don't accept PRs" language in docs/issues (quote it).
Phase 6 — Community sentiment
# HN threads (Algolia API, no auth needed). Derive the bare name from $REPO — no literal placeholders:
NAME="${REPO#*/}" # e.g. anomalyco/opencode -> opencode
curl -s "https://hn.algolia.com/api/v1/search?query=$NAME&tags=story&hitsPerPage=10"
curl -s "https://hn.algolia.com/api/v1/search?query=$NAME&tags=comment"
- Read the top HN thread(s); quote 1-2 comments verbatim with links — a quoted comment claiming the project is "open source in name only" is the canonical finding.
- Reddit/Discord/Matrix:
web_searchfor"{repo}" redditand"{repo}" maintainers; check the project's community link in README. - Maintainer acknowledgment check: search issues/PRs/discussions for maintainer comments admitting a contribution bottleneck ("small team", "PRs welcome but we prioritize the roadmap"). If found, quote and link — e.g. "even their maintainers acknowledge that this is a problem".
Phase 6b — Event fitness (Hacktoberfest)
Run this phase only when the requester mentions Hacktoberfest or an October deadline.
Hacktoberfest 2026 changed format. It is run by MLH + DEV (DigitalOcean as presenting partner) and has explicitly dropped the PR counter: "So we're not making PRs anymore? Nope, we're trying something new this year." The stated reason is that "AI tools make low-effort PRs easier than ever to generate" and maintainers face "unprecedented volume and noise." The refocus is events (300+) and building with open AI. Full 2026 rules were unpublished as of 2026-08-13 — verify at https://hacktoberfest.com/ before relying on any mechanic below.
When PRs are used they still count only if merged or labelled
hacktoberfest-accepted. Non-code work counts too: copy editing, technical docs, UX testing, digital content, graphic design — tracked via a PR. For contrast, 2025 required 6 accepted PRs on repos carrying thehacktoberfesttopic, withspam/invalidlabels for junk, Holopin badges, and a t-shirt for the first 10,000 finishers.
python3 scripts/hacktoberfest_fit.py "$REPO" --years 2025,2024
Reports, per past October: merge rate vs the Jun–Aug baseline (merge_rate_delta_pts),
volume_multiple (did it get flooded?), hacktoberfest_accepted_labelled count,
spam/invalid counts — plus current topic/label state and any anti-Hacktoberfest
language quoted from README/CONTRIBUTING.
Read it like this:
hacktoberfest_accepted_labelled> 0 is the strongest positive. Carrying the topic is free; applying the label is proof maintainers actually showed up for the event.merge_rate_delta_pts≤ −15 is the trap this phase exists to catch: the repo merges fine all year and drowns in October.- A defined
hacktoberfest-acceptedlabel with 0 uses is residue from an earlier year, not an opt-in. (appwrite/appwrite: label exists, topic dropped, 0 uses in Oct 2025.) anti_hacktoberfest_languagenon-empty ends the analysis — quote it and move on.
Cross-reference 3e latency hard here: October is a 31-day box. If outside
time_to_merge_hours p75 exceeds ~500h (~21 days), a PR opened mid-October probably will
not merge inside the window regardless of how welcoming the repo is. Report
fits_31_day_window_pct explicitly.
Phase 6c — Event fitness (GSoC)
Run this phase only when the requester mentions GSoC or Google Summer of Code.
python3 scripts/gsoc_orgs.py --check "$REPO" --history 2022 2026 # affiliation + years
python3 scripts/gsoc_orgs.py --year 2026 --match <org> # ideas + guidance links
python3 scripts/gsoc_orgs.py --year 2026 --tech rust --github --repos-out cands.txt
Data comes from the official program API
(summerofcode.withgoogle.com/api/program/{year}/organizations/, JSON, no auth, no
GitHub quota). It currently serves 2022 onward; older years 404, and the script skips
them rather than aborting, so "5 years" means "every year the API covers".
What actually decides GSoC selection — weight accordingly, and note that merge rate is NOT the top signal here:
- Org affiliation and continuity. A repo inside an org that has run GSoC every available year is a far better bet than an equally healthy unaffiliated repo: the mentors ranking your proposal are the people reviewing your PRs now. An org that participated once, three years ago, is not a GSoC target.
- Conversational responsiveness over merge throughput. The mentor guide: "Don't even
think about selecting a GSoC contributor with whom you've had no contact." Use 3e's
time_to_first_response_hoursandsilent_pct— a repo that merges well but never talks cannot produce the relationship selection depends on. - A public channel. Mailing list / Zulip / Matrix / Discord / IRC, from
contact_linksand the README. No public channel means no pre-application contact. - Work sized like a project.
good first issuequeues are typo-tier; a proposal needs ~90/175/350-hour scope. Check the org'sideas_link— that is the actual menu, and Phase 4b's inventory is only the warm-up. - Prior involvement beats proposal polish. "A mediocre proposal is much less concerning if it looks like the applicant is already moving forward."
Report the org's ideas_link and contributor_guidance_url verbatim — they are the
highest-value URLs in the whole report for a GSoC-motivated requester.
Eligibility gate — check before recommending anything. 18+, eligible to work in country of residence, student OR open-source beginner (beginner ≈ minimal experience, e.g. fewer than 10 PRs across repos), accepted into GSoC at most once before, not in a US-embargoed country. If the requester is not a student and already has substantial PR history, say plainly that the beginner path may not hold and that depth in one org beats volume across many.
Program calendar reference
When the requester names a program, convert it to dates relative to today and put the next deadline in the report. A timeless verdict is less useful than "you have 7 weeks".
Hacktoberfest — October 1–31 every year. Registration has historically opened late September and stayed open through October 31. 2026 specifics unpublished as of 2026-08-13; check https://hacktoberfest.com/.
GSoC — fixed annual shape (2026 actuals; later years shift by days, not weeks):
| Phase | 2026 date |
|---|---|
| Org applications | Jan 19 – Feb 3 |
| Accepted orgs published | Feb 19 |
| Contributor↔org discussion period | Feb 19 – Mar 15 |
| Contributor applications | Mar 16 – 31 |
| Orgs rank proposals | Apr 21 |
| Results announced | Apr 30 |
| Community bonding | May 1 – 24 |
| Coding | May 25 – Aug 24 (extended: to Nov 2) |
Eligibility: 18+, eligible to work in country of residence, student OR open-source beginner (beginner ≈ minimal experience, e.g. fewer than 10 PRs across repos), accepted into GSoC at most once before, not in a US-embargoed country. Project sizes ~90 / ~175 / ~350 hours; up to 3 proposals, only 1 can be accepted.
Timing implication to state explicitly: the discussion period is not when to start. The official contributor guide says "don't wait until the application period to initiate contact - really!" and the mentor guide is blunter: "Don't even think about selecting a GSoC contributor with whom you've had no contact." Mentors also weight prior involvement over proposal polish — "A mediocre proposal is much less concerning if it looks like the applicant is already moving forward." So for a GSoC target, the useful metrics are the 3e latency numbers and maintainer conversational responsiveness, not merge rate alone.
Phase 7 — Synthesis: the verdict
| Signal | GO | GO-IF (conditions) | NO-GO |
|---|---|---|---|
| Outside merge rate (humans only) | > 15% | 5-15% | < 5% |
| Time to first response (outside, p50) | < 3 days | 3-14 days | > 14 days or silent_pct > 40% |
| Time to merge (outside, p50) | < 14 days | 14-45 days | > 45 days |
| Available work | several clean newcomer issues |
1-2, or all taken |
available_now: 0 |
| Open PR age | most < 30d | 30-50% older than 90d | majority older than 90d |
| Outside share of open PRs | any | mostly outside | overwhelmingly outside with near-zero merges |
| First-timer outcomes (verified cohort) | some merge | few merge | closed-without-merge pattern |
| Substance of outside merges | real code merges | mostly docs | nothing non-trivial from outsiders |
| Maintainer response | engages issues/PRs | selective | acknowledges bottleneck or silent |
| Community docs | CONTRIBUTING + templates real | present, stale | missing or contradictory |
Weighting — do not average these. Some signals are gates, not votes:
- Latency and available work are gates.
available_now: 0or a p50 first response over 14 days caps the verdict at GO-IF no matter how good the merge rate is. You cannot contribute to a queue with nothing in it, and you cannot iterate with someone who does not reply. - Merge rate is the primary signal once the gates pass — it is the best single predictor and the thing the requester ultimately cares about.
- Everything else is corroborating. A single weak corroborating signal does not downgrade a verdict; state it as a condition instead.
- On a split between gates and merge rate, the verdict is GO-IF and the condition names the failing gate ("merges 62% of outside PRs but p50 first response is 26 days — go only if you can wait a month per iteration").
- n < 30 closed PRs in window: no percentage. Say "sample too small for a rate" and fall back to qualitative reading. Do not quote 2/3 as 67%.
Name the verdict explicitly — this is the answer to "should I contribute?":
- GO — outside PRs merge at healthy rates; maintainers respond; a contribution has a realistic path. Contribute.
- GO-IF — contributing is viable only under conditions; state them (e.g. "small, well-scoped bug fixes merge; features don't" / "wait until maintainer staffing changes" / "drive-by PRs ignored but issues get linked fixes"). Contribute IF the conditions hold.
- NO-GO — outside PRs mostly ignored, single-gate merges, ghost town, or license/governance blocks real contribution (the opencode case: "open source in name only"). Don't invest.
Then answer the requester's actual question directly ("should I contribute?" / "is this good to adopt?" / "what would my PR experience be?") with 3-6 risk/opportunity bullets, each carrying its evidence.
Report template
# Should I Contribute? — {owner}/{repo}
**Verdict: {GO | GO-IF | NO-GO}** · researched {date} · sample sizes in Methodology
## Executive summary
- …
## Snapshot
| field | value | | field | value |
|---|---|---|---|
## Momentum
commits/wk (52w), releases, bus factor …
## Contribution landscape
| metric | value |
|---|---|
| open PRs | N (M outside core) |
| open >30d / >90d | … |
| merge rate, humans only (closed in {window}, n={sample}) | … |
| outside-author merge rate | … |
| core-author merge rate (contrast) | … |
| **time to first response, outside (p50 / p90)** | … |
| **time to merge, outside (p50 / p90)** | … |
| **fits a 31-day window** | …% |
| **silent (no reply at all)** | …% |
| **outside merges that are substantial (≥50 lines)** | …% |
| **outside merges that are docs-only** | …% |
| first-timer merge rate ({verified cohort \| as-of-read-date}) | … |
| closed-unmerged first-timers | … |
## Available work
| metric | value |
|---|---|
| newcomer labels found | … |
| distinct newcomer issues / unassigned | … |
| available now (verified no open PR) | … |
| already taken by an open PR | … |
Top candidates:
- #N — title — {age}d old — {url}
## Issue health
…
## Governance & maintainability
…
## Community sentiment
> quote (source link)
## Event fitness — {Hacktoberfest | GSoC}
_only when the requester asked about one_
## Risks & opportunities
…
## Methodology & caveats
- endpoints used, sample sizes, research date, rate-limit constraints
- window basis: closed_at, half-open UTC; fetched-vs-aggregate drift = …%
- author_association split definition, and whether the first-timer cohort was verified
or is as-of-read-date
- bot-exclusion rules
- latency sample size (first-response is sampled, time-to-merge is full population)
- known blind spots (mirror/fork contributions, private core branch, etc.)
Caveats — read before reporting
- Unmerged ≠ rejected: PRs get superseded, closed by staleness, or merged via maintainer re-commit. Always sample the actual
merged_atfield; never infer from labels. pulls?state=closedsorts by created_at, NOT closed_at — never call a pulls-page sample "last N closed". Use the slice-basedclosed:window (3b) for exact closed-at sampling.- Search result items report merged status at
.pull_request.merged_at— top-level.merged_atis always null in search results. And never sample withsort=updated/sort=created: ordering is relevance/activity-based and empirically unusable for rates. - Search caps item results at 1000 per query —
total_countstays exact, but per-item breakdowns need window slicing (day/week chunks ≤ 1000, full pagination, dedupe by number) per 3b. - Bot authors (dependabot, renovate, GitHub Actions) inflate merge rates and contributor counts — exclude
user.type == "Bot"and logins ending[bot](some bots are typedUser) and note it.pr_census.pyreports both figures; quoterates_humans_only. author_associationis evaluated at read time, not PR time — see the 3c warning. Success relabels first-timers toCONTRIBUTORand departed maintainers to outsiders, so historical first-timer rates are biased low and historical core/outside splits are unreliable. Verify with--verify-firsttimersor label the cohort as-of-read-date.- Small repos: n < 30 closed PRs → say "sample too small for rate" instead of quoting a misleading percentage.
- Merge rate without latency is half a metric. Always report time-to-merge and time-to-first-response beside it; a 60% rate on a 90-day median is a different project from a 60% rate on a 3-day median.
- A healthy merge rate does not imply available work. Run Phase 4b before any GO.
- Rate limits — core and search are metered SEPARATELY. Core: 5,000/hr authenticated, 60/hr unauthenticated. Search: 30/min authenticated, 10/min unauthenticated — the day-slice census is entirely search, so a 90-day run takes ≥3 min authenticated / ≥9 min unauthenticated and will 403 without pacing. The scripts pace and retry; hand-rolled loops do not. On 403, authenticate or shrink the window — never fabricate numbers.
ghis optional but recommended. The scripts fall back tocurlwith$GITHUB_TOKEN, then to unauthenticated. Unauthenticated, budget roughly one 14-day-window repo per hour; a bare token raises search from 10/min to 30/min and core from 60/hr to 5,000/hr, which is the difference between one repo and twenty.- Numbers decay: state the research date; merge rates move.
- Event rules decay faster than numbers. Hacktoberfest changed format and steward for 2026; GSoC dates shift yearly. Re-read the official source rather than trusting anything cached in this file.
- Forks and mirrors: contribution may happen in a separate repo (e.g., a "community edition") — check the README's repo links before concluding.
Workspace etiquette
- The report is the deliverable: save it as
contrib-scout-{repo}.mdwhen the user wants a saved artifact, otherwise print it. - Keep the raw JSON you computed from (optionally) — reference it in Methodology.