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, first-timer outcomes
- 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
- 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.
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. ghCLI authenticated → 5,000 req/hr. USE IT if available:gh api "repos/$REPO/...". Ifghis not authenticated,gh auth loginor fall back to curl with a token:curl -H "Authorization: Bearer $GITHUB_TOKEN" "https://api.github.com/repos/$REPO/...".- Raw unauthenticated curl → 60 req/hr core, 10/min search. The full analysis needs ~20-40 requests. Budget them; do NOT repeat paginated calls wastefully. If you hit 403 rate-limit, authenticate or shrink the sample.
- 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 (or week) slices, fetch EVERY result in each slice, dedupe, aggregate. Ordering becomes irrelevant and the 1,000-result search cap is avoided. This is the tested implementation (run via python3):
import json, subprocess, datetime
from collections import Counter
repo = "owner/name"; days = 90 # <-- set these
end = datetime.date.today(); start = end - datetime.timedelta(days=days)
def iso(d): # day-only bounds ("closed:A..B") are INCLUSIVE of both full days and span B-A+1 days —
return d.strftime("%Y-%m-%dT00:00:00Z") # use explicit hour bounds for a true half-open [A, B)
def search(q, page=1):
url = f"search/issues?q={q}&per_page=100&page={page}"
out = subprocess.run(["gh", "api", url], capture_output=True, text=True)
if out.returncode != 0:
raise RuntimeError(out.stderr[:300])
return json.loads(out.stdout)
items, seen = [], set()
for d in range(days):
s = start + datetime.timedelta(days=d)
q = f"repo:{repo}+is:pr+is:closed+closed:{iso(s)}..{iso(s + datetime.timedelta(days=1))}"
r = search(q) # page 1 doubles as the total_count probe
total = r["total_count"]
assert total <= 1000, f"slice {s} too large ({total}) — split it by hour and recurse"
page = 1
while True:
its = r.get("items", []) if page == 1 else search(q, page).get("items", [])
for it in its:
if it["number"] not in seen:
seen.add(it["number"]); items.append(it)
if len(its) < 100: break
page += 1
r = None
# INVARIANT: fetched population must equal the aggregate closed: total_count.
# Both use half-open [start 00:00Z, end 00:00Z) — exactly `days` full days (through
# yesterday). If they differ, there is a slice gap — do NOT report rates until it matches.
last = end - datetime.timedelta(days=1)
agg = search(f"repo:{repo}+is:pr+is:closed+closed:{iso(start)}..{iso(end)}")["total_count"]
assert len(items) == agg, f"fetched {len(items)} != aggregate {agg} — gap in slicing, abort rates"
merged = [i for i in items if i.get("pull_request", {}).get("merged_at")]
if items:
print(f"window {start}..{last}: n={len(items)} merged={len(merged)} rate={len(merged)/len(items)*100:.1f}%")
else:
print(f"window {start}..{last}: n=0 (no PRs closed in window)")
# Funnel cross-tab (3d): merged vs closed-unmerged per author_association bucket.
tab = Counter((i["author_association"], "merged" if i.get("pull_request", {}).get("merged_at") else "unmerged") for i in items)
for (assoc, outcome), n in sorted(tab.items()):
print(f"{assoc:24s} {outcome:9s} {n}")
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+ requests (1 per day slice, plus one per extra 100-item page) — fine with authed gh (5,000/hr); with unauth curl (60/hr) shrink the window to 7-14 days (fewer slices, exact within it) 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.
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." 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.
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 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 7 — Synthesis: the verdict
| Signal | GO | GO-IF (conditions) | NO-GO |
|---|---|---|---|
| Outside merge rate | > 15% | 5-15% | < 5% |
| 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 | some merge | few merge | closed-without-merge pattern |
| Maintainer response | engages issues/PRs | selective | acknowledges bottleneck or silent |
| Community docs | CONTRIBUTING + templates real | present, stale | missing or contradictory |
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 (closed in {window}, n={sample}) | … |
| outside-author merge rate | … |
| first-timer merge rate | … |
| closed-unmerged first-timers | … |
## Issue health
…
## Governance & maintainability
…
## Community sentiment
> quote (source link)
## Risks & opportunities
…
## Methodology & caveats
- endpoints used, sample sizes, research date, rate-limit constraints
- author_association split definition
- bot-exclusion rules
- 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 note it. - Small repos: n < 30 closed PRs → say "sample too small for rate" instead of quoting a misleading percentage.
- Rate limits: unauthenticated GitHub is 60 core req/hr + 10/min search. Budget: snapshot (3) + momentum (3+) + open-PR pages + closed-window day slices (1 req/day + one per extra 100-item page ≈ 8-10 req for 7 days, ~90+ for 90 days) + searches (2-4) ≈ 25-45 for the short-window variant. On 403, authenticate or shrink the window — never fabricate numbers.
- Numbers decay: state the research date; merge rates move.
- 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
should-i-contribute-{repo}.mdwhen the user wants a saved artifact, otherwise print it. - Keep the raw JSON you computed from (optionally) — reference it in Methodology.