How to Tell If a Project Will Merge Your PR
Four numbers predict whether a maintainer will read your diff, and the star count is not one of them. Includes the forty-line screener and the blind spot it has.
I picked a repo to contribute to because it had 2,700 stars and a clean open issue. Two days later the PR had no review, no comment, nothing — and when I actually looked at the project's history, that was entirely predictable.
The repo hadn't been pushed to in 16 days. Its oldest open pull request was 315 days old. Not a single outside contributor had been merged in the previous month.
None of that is visible from the star count. All of it is one API call away.
Four numbers that predict a merge
Stars measure how many people liked the README. They say nothing about whether a maintainer will read your diff. These four do:
| Signal | Reject when | Why |
|---|---|---|
| Days since last push | > 21 | Nobody is home. |
| Age of oldest open PR | > 180 days | A graveyard. Yours joins it. |
| Outside PRs merged in 30 days | 0 | They merge their own work, not yours. |
| Median outsider time-to-merge | > 14 days | Slow even when it works. |
The third is the one that matters most, and it's the one people skip. A repo can be pushed to daily and still never merge a stranger — that's just a team working in public. What you need to know is whether outsiders get merged, which is a different question from whether the project is alive.
Telling an outsider from a maintainer
This is the only fiddly part. GitHub's REST API exposes author_association on each pull request — MEMBER and OWNER are the team, CONTRIBUTOR and FIRST_TIME_CONTRIBUTOR are everyone else.
gh pr list --json does not expose that field. You have to go through the REST endpoint:
gh api "repos/OWNER/REPO/pulls?state=closed&per_page=100"Without it your numbers are meaningless. One project I screened showed a 0.5-day median time-to-merge, which looked excellent until I filtered out the core committers merging their own work — the median for actual outsiders was a different number entirely, drawn from a different population.
What it says about real repos
Run against the repo I picked badly, and one I picked well:
[AVOID] GodsScion/Auto_job_applier_linkedIn 2737* Python
pushed 16d ago | open human PRs 14 | oldest 315d
outsider merges: 14 (0 in 30d) | median TTM 14.7d
-> PR graveyard (oldest 315d), no outsider merged in 30d, slow TTM
[PASS] apache/datafusion-ballista 2115* Rust
pushed 0d ago | open human PRs 44 | oldest 122d
outsider merges: 37 (37 in 30d) | median TTM 0.8dFewer stars, better project to contribute to. Every outside PR merged in Ballista's last hundred closed PRs landed within the past month, at a median of under a day.
Screening 68 candidate repos this way, 11 passed. Most were rejected for being dormant, which is not something you can see by looking at them.
The failure mode I walked into anyway
One of the 11 was a repo I'd already rejected by hand, days earlier, as a contribution farm — the kind that auto-generates issues like "Add new Trivia Question 19" so people can pad a contribution graph.
It passed with the best numbers in the whole set: 72 outside contributors merged in 30 days, median time-to-merge 0.0 days.
Of course it did. Instant merging is the entire product. A farm is, by construction, maximally responsive to outsiders. My screen was measuring exactly the thing farms optimise for and calling it a virtue.
Another one had 163 stars and 85 outside merges in a month, which is a rate no small project sustains on real work.
So responsiveness is a filter you apply after deciding the work is real, never instead of it. Both questions have to be asked, and they don't collapse into one number:
- Is the work real? — human judgement. Read the issues. Do they describe defects with reproductions, or tasks invented to be completed?
- Will they merge it? — the four numbers above.
Skip the first and you get a fast merge that's worth nothing. Skip the second and you get good work that sits for a year.
The script
import json, subprocess
from datetime import datetime, timezone
now = datetime.now(timezone.utc)
def gh(*a): return json.loads(subprocess.run(["gh", *a], capture_output=True, text=True).stdout)
def age(ts): return (now - datetime.fromisoformat(ts.replace("Z", "+00:00"))).days
def screen(repo):
r = gh("api", f"repos/{repo}")
# REST, not `gh pr list` - only this exposes author_association
raw = gh("api", f"repos/{repo}/pulls?state=closed&per_page=100")
opn = gh("pr", "list", "--repo", repo, "--state", "open",
"--limit", "100", "--json", "number,createdAt,author")
OUTSIDER = ("CONTRIBUTOR", "FIRST_TIME_CONTRIBUTOR", "FIRST_TIMER", "NONE")
outside = [p for p in raw if p.get("merged_at")
and p.get("author_association") in OUTSIDER
and "[bot]" not in p["user"]["login"]]
ttm = sorted((datetime.fromisoformat(p["merged_at"].replace("Z","+00:00"))
- datetime.fromisoformat(p["created_at"].replace("Z","+00:00"))).days
for p in outside)
recent = [p for p in outside if age(p["merged_at"]) <= 30]
oldest = max((age(o["createdAt"]) for o in opn), default=0)
flags = []
if age(r["pushed_at"]) > 21: flags.append("stale")
if oldest > 180: flags.append("PR graveyard")
if not recent: flags.append("no outsider merged in 30d")
if ttm and ttm[len(ttm)//2] > 14: flags.append("slow outsider TTM")
print(repo, "->", ", ".join(flags) if flags else "PASS")Roughly forty lines, three API calls per repo. It would have saved me two PRs sitting in a repo that hasn't merged a stranger since spring.
The short version
Before you spend an evening on a fix, spend ten seconds on this: when did an outsider last get merged here?
If the answer is "not this month," your PR is not the exception.