Nobody Was Blocking My Pull Requests
I keep my notes in a private git repo. When I use an AI coding agent from my
phone, it can’t always reach my desktop, so it works on a cloud copy instead:
it pushes a branch and opens a pull request rather than committing straight to
master.
That’s sensible behavior. The result, though, was a slowly growing pile of branches — about ten days’ worth — that turned into open pull requests from me, to me, which I was convinced I couldn’t merge. GitHub wouldn’t let me approve my own pull request, and I had quietly assumed that meant I couldn’t merge it either. So the pile grew.
It was four PRs deep before I actually checked that assumption.
Self-approval is not self-merge
GitHub blocks you from approving your own pull request. It has never blocked you from merging one. Those are different operations, and only the first is restricted.
The thing that would tie them together is a branch protection rule requiring an approving review. I didn’t have one. I couldn’t have had one: branch protection and rulesets are a paid feature for private repositories, and the API says so in the bluntest way available.
$ gh api repos/OWNER/REPO/branches/master/protection
{
"message": "Upgrade to GitHub Pro or make this repository public to
enable this feature.",
"status": "403"
}
Nothing was enforcing review because nothing could enforce review. The merge button had been live the entire time. Four PRs merged in about ninety seconds once I stopped believing otherwise.
The lesson generalizes past GitHub: I had inferred a rule from a related restriction and never tested it. One API call would have saved the whole detour. If you’re routing around a constraint, it’s worth confirming the constraint exists.
Automating the rest
Merging by hand isn’t the fix, since the branches keep arriving. GitHub’s own auto-merge would be the obvious tool, but it’s behind the same paywall — the setting simply refuses to turn on. So: a scheduled workflow that does the merging itself.
The design question that mattered was when. Merging a PR the moment it’s mergeable is tempting and wrong: a mobile session pushes several commits as it works, and an eager workflow will merge the first one while I’m still typing, splitting one session across three merges.
So the rule is quiescence. Merge a PR only once its newest commit has stopped moving for fifteen minutes, which is a decent proxy for “the session is over.”
last=$(gh pr view "$num" --json commits --jq '.commits[-1].committedDate')
age_min=$(( ( $(date -u +%s) - $(date -u -d "$last" +%s) ) / 60 ))
if [ "$age_min" -lt "$QUIET_MINUTES" ]; then
echo "Active ${age_min}m — still working, skip."
continue
fi
Conflicts get a label and a comment instead of a forced merge. And because
sessions don’t always open a PR — sometimes a bare branch just appears — the
sweep opens one for any branch that lacks it, and deletes branches whose
commits are already in master.
Four things that bit me
Writing the workflow was quick. Making it actually work took four rounds, each one a thing I didn’t know.
A push made with GITHUB_TOKEN doesn’t trigger other workflows. This is
the one that would have quietly broken something real. My repo has a second
workflow that publishes web content on push. Auto-merged content changes would
have landed in master and never published — no error, no failure, just a site
that silently stopped updating. GitHub does this deliberately to stop workflows
triggering themselves in a loop. The escape hatch is that workflow_dispatch
is something a token may fire, so the merge workflow now calls the publish
workflow explicitly when the merged files warrant it.
GitHub Actions can’t create pull requests by default. Branch adoption failed
with GitHub Actions is not permitted to create or approve pull requests. It’s
a repository setting (Settings → Actions → General), invisible from the
workflow file, and off by default. Note the wording carefully: the same toggle
that lets Actions create PRs also lets it approve them. On a repo with
required reviews, that’s a control worth thinking about before you flip it. On a
personal notes repo with no reviews to bypass, it’s fine.
A line at column 0 terminates a YAML block scalar. I embedded a multi-line
PR comment inside run: |, with the body starting at the left margin. That ends
the block, which makes the file invalid, which meant GitHub couldn’t register
the workflow’s triggers at all. The failure presented as “your manual trigger
doesn’t exist” rather than “your YAML is broken.” Build multi-line strings with
printf, or keep every line indented past the block.
gh pr list --json commits can exceed GitHub’s GraphQL node limit. Asking
for the commits of fifty PRs in one query requested about 510,000 nodes against
a 500,000 cap. Every run failed. Fetching each PR’s commits individually is more
requests and no drama.
Test it for real
Every one of those four surfaced because I tested against actual pull requests rather than reading the workflow and calling it done. The YAML break in particular is invisible to inspection — the file looks correct, and GitHub reports it as a missing trigger somewhere else entirely.
So I pushed a real branch with no PR and watched the whole chain run: branch
adopted, PR opened, merged, branch deleted. Then a fresh PR, correctly skipped
with Active 3m < 15m, and merged on demand with the quiet window set to zero.
Worth saying plainly: this auto-merges unreviewed changes into master. That’s
the right call for a personal notes vault where the alternative is a pile of
PRs I never look at. It would be the wrong call for a repo with collaborators, a
deploy pipeline, or anything where a bad merge costs more than a revert. The
mechanism is easy; knowing whether you want it is the actual decision.