17 PRs a Day, One QA: How We Automated E2E Failure Triage At pdf.net, a single QA engineer automated end-to-end test failure triage using a GitHub Action and Claude API, handling 17 daily pull requests from 18 AI-augmented developers. The system analyzes failed tests, diffs, and logs to classify failures as PR-related, flaky, or infrastructure issues, then creates tickets and Slack notifications accordingly. The entire solution costs less per month than one hour of engineering time. Picture this: 18 AI-augmented developers merging an average of 17 pull requests every working day — 33 on the peak day. And at the time, exactly one QA engineer watching over all of it. The fact that things didn't fall apart daily looks like a miracle. Spoiler: it's not a miracle, it's e2e tests on staging — plus a bot that triages their failures so humans don't have to. I'm that QA engineer. Here's how we at pdf.net https://pdf.net/ got a GitHub Action + Claude to triage every red run: the architecture, the code, and the rakes we stepped on — the bot blaming innocent people, losing tickets, and staying silent when silence was the worst option. Headline number up front: the whole thing costs less per month than one hour of engineering time. Our team is AI-first by design: developers ship with AI assistants, and instead of scaling manual testing along with headcount, we bet on automation. Autotests aren't a safety net "just in case" — they're the primary trigger that we're doing something wrong. We release once a day, and originally the release was where everything got verified: fifteen-plus PRs roll up, tests run, and if something's red, the one QA engineer investigates. A single release could contain several broken PRs at once, each to be identified among 17+ suspects while the release waits. Mornings turned into detective work. So we shifted the loop left. Quick infra note: every PR gets a Vercel preview a dev environment — running the full e2e scope there makes no sense: it's slow, and half the features sit behind flags that don't match production , while staging is effectively pre-prod: every merge lands there, and code ships to production from there. E2e now runs on every merge to staging — the suspect range becomes one fresh merge instead of a day's batch, and the release turns into a final "works assembled" check plus manual testing that automation can't cover. The price: red runs now happen potentially after every one of those 17 merges, each needs a quick investigation, and until it's done the release is in question. Hiring a person for "look at a failed run and decide who to hand it to" is overkill — that's not a role, it's a distraction. The reasoning wrote itself: if our tests are automation, triaging their failures should be automation too. Handily, the failure sources are a closed list — a PR regression in the main repo, the neighboring backend repo its deploys trigger our e2e too , external services, CI infrastructure, or a flake. Manual triage was always the same mechanical chain over structured inputs: failed tests, diffs, logs → a three-way call. Exactly the kind of task LLMs are good at. The triage is a reusable composite GitHub Action, invoked after every e2e run — red and green. Around it: Allure TestOps test results , Linear tickets , Slack notifications , Claude API the brain . e2e run on staging │ red ──────────┴────────── green │ │ AI verdict Recovery: close tickets, │ rebase-nudge PR authors ┌──────────┴───────────────┐ likely-pr-related likely-not-pr-related │ / insufficient-data Linear ticket │ + Slack thread Slack heads-up + ONE auto-rerun │ ┌──────────┴──────────┐ rerun green rerun red │ │ it was a flake — escalation: ticket note it, move on + @oncall mention On a red run the bot collects failed tests with steps from Allure, job logs, the deploy commit range, and the diffs of the PRs that went in — and asks Claude Opus 4.8, Sonnet 5 fallback for a verdict plus a fix hypothesis: js const verdict = await askClaude { failedTests, jobLogs, deployRange, prDiffs } // = 'likely-pr-related' | 'likely-not-pr-related' | 'insufficient-data' if attempt === 1 && verdict == 'likely-pr-related' { await slack.post headsUp verdict // "doesn't look PR-related — rerunning" return requestRerun // exactly one auto-rerun } // PR-related, or a red rerun → Linear ticket + Slack post, // on a red rerun — with an on-call mention await createLinearIssue { verdict, hypothesis, failedTests } The key decision: the verdict picks a route, it doesn't just write text. PR-related → ticket with a hypothesis and a Slack thread. Anything else → no ticket yet: a heads-up and one auto-rerun. Green rerun — it was a flake, no noise. Red rerun — ticket plus an on-call mention. 🔴 e2e failed on staging — 3 tests Verdict: likely-pr-related Hypothesis: regression in PR 1234 — the file upload handler changed, tests fail on the "attach document" step Ticket: QA-482 · Staging: 🔴 red — release on hold Tickets land in one Linear queue, with labels telling frontend deploys from backend ones. One manual habit pays for it all: a confirmed real bug gets a bug label — so "how many bugs did autotests catch, and how much was noise" is a plain queue filter. Noise control, four mechanisms: dedup repeat failures match open auto-tickets instead of spawning new ones , auto-close green run closes the ticket, posts recovery with outage duration, and nudges open PRs to rebase , two channels ticket-backed posts go to the important channel, rerun chatter stays in the working one , and manual muting tickets closed without a fix go to Canceled, not Done — recovery leaves them alone . Neat on paper. Now the four rakes. Recovery writes to three places: a Slack post, the Done transition, and a recovered-at: