Pull Request CI Monitor
SkillMonitoring & opsWatches an open pull request's CI until green and fixes failing checks. TRIGGER when: the user wants to watch a pull request's CI, babysit a PR until it goes green, or fix failing CI checks on an open PR. DO NOT TRIGGER when: opening the PR in the first place → pr; rebasing the branch onto its base → rebase.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Pull Request CI Monitor skill
What this skill tells your AI
The instructions your AI receives, as published by opsmill/infrahub in .agents/skills/monitoring-pull-requests/SKILL.md and read by ahel’s review.
Introduction
Watch the CI for a pull request, and when something fails, fix it the careful way: reproduce the failure locally before changing anything, validate the fix locally before pushing, and retry up to 5 times. If 5 fix attempts still leave the PR red, revert every fix-attempt commit — with the user's approval — and hand back the PR in its original state; five failed attempts is a strong signal the changes are misguided.
Push-without-local-reproduction is allowed only when the failure cannot be reproduced locally (genuine CI-only divergence). Never push a speculative fix when local reproduction is possible.
This skill is project-agnostic: it discovers how to reproduce CI failures from the project's own context (workflow definitions, AGENTS.md/CLAUDE.md/CONTRIBUTING.md, Makefile/Taskfile/justfile, package.json scripts, pyproject.toml/tox.ini, pre-commit config) rather than assuming a fixed toolchain.
Execution Mode
This skill is designed to run as a background agent, not in the user's foreground turn. CI commonly runs for tens of minutes, and a fix-and-retry loop multiplies that by up to 5 iterations. Blocking the parent conversation that long is wasteful — spawn this skill as an asynchronous agent and let it report back when it has something to say.
- Recommended invocation: by another skill (notably
prPhase 7) using the runtime's background-agent primitive (Claude Code:Agentwithrun_in_background: true; equivalent in other runtimes). - Acceptable invocation: directly by the user as
/monitoring-pull-requestswhen they explicitly want to watch a PR. In that case the loop runs in the foreground and the user can interrupt. - The skill is self-contained: it captures
baseline_commititself in Phase 0 and resolves the PR/branch from arguments or current branch. No state needs to be inherited from the spawning conversation beyond what is passed in$ARGUMENTS.
Arguments
$ARGUMENTS
Supported arguments:
- (no args) — Use the PR for the current branch.
<PR-number>— Use the PR with that number on the current repo.<PR-URL>— Use the PR at that GitHub URL.
State
Maintain these variables throughout the run. Track them in your scratch space; they drive the retry/revert logic.
iteration = 0
max_iterations = 5
pr_number = <resolved in Phase 0>
branch = <resolved in Phase 0>
baseline_commit = <HEAD on the branch when the skill starts — the revert target>
fix_commits = [] # SHAs created by this skill while attempting fixes
ci_resolved = false
attempt_log = [] # One entry per iteration
Every fix attempt produces exactly one new commit on the branch, appended to fix_commits. This is what makes the Phase 6 revert deterministic.
Phase 0: Resolve Target PR & Baseline
-
Move to the repo root:
cd "$(git rev-parse --show-toplevel)"If this fails, STOP — not inside a Git working tree.
-
Resolve the PR. Determine
pr_numberandbranch:- If
$ARGUMENTSlooks like a number →pr_number = $ARGUMENTS. - If
$ARGUMENTSlooks like a URL → extract the number withgh pr view "$ARGUMENTS" --json number -q .number. - Otherwise →
pr_number = $(gh pr view --json number -q .number)for the current branch. If no PR exists, STOP and tell the user to open one (or run/prfirst).
Then capture metadata:
gh pr view <pr_number> --json number,headRefName,headRefOid,baseRefName,state,isDraft,mergeable,statusCheckRollupbranch = headRefName(and keepheadRefOidat hand — step 4 verifies the local baseline against it)
- If
-
Check out the PR branch locally (if not already):
git fetch origin git checkout <branch> git pull --ff-only origin <branch>If
git pullis not fast-forward, STOP and tell the user the local branch has diverged from origin — they need to reconcile first. -
Record
baseline_commitas the current HEAD. This is the local revert target if all 5 iterations fail:baseline_commit=$(git rev-parse HEAD)Verify it matches
headRefOidfrom step 2. If they differ, STOP — the branch is out of sync with the PR head and any revert would lose work. -
Refuse to operate on long-lived branches. If
branchis the repository's default branch (git symbolic-ref --short refs/remotes/origin/HEAD), a long-standing integration branch (main,master,stable,develop,dev,trunk), or a release branch (release/*,release-*, version-named branches), STOP. This skill is for feature PRs only. -
Discover the project's local toolchain (used by Phases 2–4). Read whatever project context exists —
AGENTS.md/CLAUDE.md/CONTRIBUTING.md,Makefile/Taskfile/justfile,package.jsonscripts,pyproject.toml/tox.ini,.pre-commit-config.yaml— and note:- The lint/format gate (the command developers run before pushing).
- The test runner(s) and how to target a single test.
- Any code-generation steps whose outputs are checked in (GraphQL schemas, OpenAPI clients, protobufs, generated SDKs) — CI often fails when these are stale.
-
Report to the user: PR number, title, branch, baseline commit short SHA, current statusCheckRollup summary (counts of pending/success/failure). Then proceed.
Phase 1: Monitor CI
The goal of this phase is to discover whether the CI is currently passing, failing, or still running — and to wait through "still running" states.
-
Find the most recent CI run for the branch:
gh run list --branch <branch> --limit 10 --json databaseId,name,status,conclusion,event,headSha,createdAtFilter to runs whose
headShamatches the current branch tip. If multiple workflows ran for the same SHA, look at all of them — the PR is only green if every required check is green. -
Wait for in-flight runs to settle. While any run for the current SHA has
statusother thancompleted:- Poll every 60 seconds:
gh run list --branch <branch> --limit 10 --json databaseId,status,conclusion,headSha. Where available, prefer a single blockinggh run watch <run-id>over polling. - Be patient — CI can take 30+ minutes on repos with heavy integration or end-to-end suites.
- If nothing has appeared after 5 minutes from a fresh push, investigate whether the push triggered a workflow at all (
gh run list --branch <branch> --limit 5). Stalled webhooks happen.
- Poll every 60 seconds:
-
Once all runs for the SHA are
completed:- If every run's
conclusion == success(orskippedfor legitimately skipped jobs) → setci_resolved = true, go to Phase 5. - If any
conclusion in {failure, cancelled, timed_out, action_required}→ go to Phase 2.
- If every run's
-
If
iteration == 0and CI is already green when the skill is first invoked: report this to the user and exit cleanly. Nothing to do.
Phase 2: Classify Failures
For each failed run, list its failed jobs and work out how to reproduce each one locally.
Failure outside the PR's diff — don't trust a stale "commits behind" count. A failure in a file or config the PR's diff doesn't touch may be inherited from the base — but the "how far behind" distance is a snapshot, not a durable property. It can go stale the moment the base gets fixed, including while you're escalating or waiting on a reviewer reply — a branch that was genuinely 0 commits behind an hour ago is not proof it still is now.
- Re-fetch and recompute immediately before deciding, never reuse a distance computed earlier
in the session:
git fetch origin && git rev-list --count HEAD..origin/<base-branch>. - Reproduce against the base's current tip, not the branch's original merge-base — if the failure is gone there, a rebase is the fix, not an escalation.
- A reviewer's "this needs a rebase" is a cue to re-verify, not a claim to argue against with a distance computed earlier — that number may no longer be true.
- Cite a specific run, not an impression. To prove a failure predates your branch, find a run of
the same job on the base branch (
gh run list --branch <base-branch> --json databaseId,conclusion,headSha,createdAt) and confirm it failed the same way — a concrete run ID beats an inferred "probably pre-existing." - To test whether your own commit caused a failure without burning the run, re-run the failed job
on the same SHA (
gh run rerun <run-id> --failed) instead of pushing a fix-attempt commit first — a workflow withconcurrency: cancel-in-progress: truecancels the very run whose outcome would answer the question.
-
List failed jobs for each failed run:
gh run view <run-id> --json jobs --jq '.jobs[] | select(.conclusion=="failure") | {name, databaseId, conclusion}' -
Classify each failed job into a track. Read the workflow definition that ran the job (under
.github/workflows/) to see the exact commands CI executed, then map the job onto one of these tracks:Track Typical job names Local reproduction strategy lint lint, pre-commit, format, static analysis Re-run the specific failing hook/rule on the specific failing files (Phase 3.lint) test unit tests, integration tests Re-run the exact failing test case(s) with the project's test runner (Phase 3.test) build build, compile, typecheck, packaging Re-run the project's build/typecheck command (Phase 3.build) e2e e2e, acceptance, browser tests, anything needing heavy infrastructure Target the single failing test; never the full suite (Phase 3.e2e) other publish, release, deploy, bots, unfamiliar workflows Read logs and ask the user before guessing The local-equivalent command for each track comes from the toolchain discovered in Phase 0 step 6 and from the workflow file itself — when in doubt, run locally exactly what CI ran, narrowed to the failing case.
-
Pull the failing log lines for every failed job. Don't dump the whole log; extract the failing test/check:
gh run view <run-id> --log-failed > /tmp/monitoring-pull-requests-failed-<run-id>.logGrep for the runner's failure markers (e.g.
FAILED/ERRORfor pytest,FAILfor vitest/jest/go test,error[for compilers) — those give the exact test IDs, hook names, or file/rule pairs to reproduce. -
Always reproduce as narrowly as possible. The goal is the smallest local invocation that exercises the failing case, not a full-suite re-run. A run targeting one test finishes in seconds; a full suite can take many minutes. Narrow runs are also clearer to debug — less noise in the output. Concretely:
- Pull the exact failing test IDs / file paths / hook names from the log first. Don't fall back to a broad suite while there are specific names to try.
- If the first narrow command fails to reproduce or the test ID was wrong (typo, parametrized name not matched), try a slightly broader form — drop the parameter suffix, then step up to the file, then the directory. Widen one notch at a time, never jump straight to the full suite.
- Only fall back to the full-suite invocation as a last resort, and only after at least two narrowed attempts didn't pinpoint it. Note the fallback explicitly in
attempt_log. - Once the fix is in, re-run the narrow command first to confirm the fix; you may then run the broader suite as a regression check before pushing — but keep the reproduction step narrow.
-
Decide the order of attack. Fix one failure per iteration. Prefer the cheapest one first (lint < test < build < e2e), because lint failures often mask deeper issues and are fast to fix. Record the chosen target in
attempt_logfor this iteration:attempt_log[iteration] = { failing_jobs: [<list of job names that failed>], target_job: <the one being fixed this iteration>, target_node: <test id or hook name being reproduced>, failure_excerpt: <a few key log lines>, }
Phase 3: Reproduce Locally and Fix
Hard rule: never push a speculative fix when local reproduction is possible. Reproduce first, then fix, then re-verify locally, then push.
The reproduction strategy depends on the track. Pick the matching subsection. In every subsection, the concrete commands come from the project's own toolchain (Phase 0 step 6) — the examples below illustrate the narrowing pattern, not a required tool.
3.lint — Lint / Format / Pre-Commit
Narrow first: run only the hook(s) or rule(s) that failed, on only the file(s) they flagged.
- From the failed-job log, identify the specific hook/rule id and the file paths it complained about.
- Reproduce that check in isolation against just those files — e.g. with pre-commit:
pre-commit run <hook-id> --files <file1> <file2>; with a direct linter: point it at the flagged files only. - If the narrow run doesn't reproduce, widen one notch — the same check across all files.
- Only as a last resort, run the project's full lint/format gate the way CI runs it.
- If the failure reproduces:
- Read the failing rule/file.
- Apply the minimal fix — formatter auto-fixes are fine; for type errors, fix the actual type.
- Re-run the same narrow command to confirm. Then run the full gate once before pushing as a regression check.
- If the failure does not reproduce even at the broadest local invocation (rare for lint, but possible — version drift, OS-specific): document this in
attempt_logand proceed to Phase 4 with a best-effort fix. Note the unreproduced status.
3.test — Unit / Integration Tests
Narrow first: run only the failing test case(s), not the full suite.
- Identify the exact failing test IDs from the log (Phase 2 step 3).
- Reproduce one case at a time using the project's test runner — e.g.
pytest -v <node_id>,vitest run <file> -t "<name>",go test -run '<TestName>' ./<pkg>,cargo test <name>. Run multiple failing cases in a single invocation if there's more than one — still narrower than the full suite. - If a test ID doesn't match (typo, parametrized id mismatch, file moved), widen one notch at a time: drop the parameter suffix → fall back to the file → then the directory. Do not jump straight to the full suite.
- Only as a last resort, after at least two narrowed attempts have failed to pinpoint the failure, re-run the suite the way CI does (copy the exact invocation from the workflow file). Note in
attempt_logthat you fell back to the full suite and why. - If it reproduces:
- Read the failure output. Read the test. Read the code under test.
- Apply the minimal fix — fix the code if the test asserts the right behavior; fix the test if the test is wrong. Be sure which is which before changing either.
- Re-run the same narrow command until it passes. Then run the broader suite once before pushing as a regression check (but only if the narrow fix landed cleanly).
- If it does not reproduce locally even at the narrow level: gather environment evidence (language/runtime version, dependency versions, lockfile diff vs. CI), document, and proceed to Phase 4 with a tentative fix. CI-only failures justify a push without local repro.
3.build — Build / Typecheck / Packaging
- Re-run the build command CI ran (from the workflow file or the project's own scripts).
- Builds are already a single command; the narrowing instinct applies to the fix: change only the file that produced the error, not the surrounding module.
- If it reproduces, fix it the same way (minimal, scoped). Re-run the build to confirm before pushing.
- If it does not reproduce: document and proceed.
3.e2e — E2E / Expensive Suites
E2E failures are the most expensive to reproduce locally (heavy infrastructure, minutes per run, often 30+ minutes for the full suite). Never reproduce by running the whole E2E suite. Always target the specific failing test.
- Identify the specific failing test ID from the failed-job log, including any parametrized variant suffix — preserve it exactly when reproducing.
- If multiple E2E tests failed in CI, pick one to focus this iteration on — typically the first one to fail, or the one whose failure mode is clearest. Don't try to debug several E2E tests in parallel.
- If the project provides a dedicated E2E troubleshooting skill, command, or runbook (check the project's skills/commands directories and
AGENTS.md), delegate to it with that single test ID, and treat its entire outcome as a single iteration ofmonitoring-pull-requests— even if it has its own internal retries. - Otherwise, reproduce the single test locally using the project's documented E2E invocation, narrowed to that one test. If local E2E infrastructure isn't available, treat the failure as not-locally-reproducible: gather evidence from the CI logs and artifacts before considering a push.
- If the fix passes locally for the targeted test, proceed to Phase 4.
- If no fix works, or the failure may be CI-only: compare the local failure mode against the CI failure. If they genuinely match but no fix worked, treat this iteration as exhausted — record it and either move on to a different failing job (if any) or count it toward
max_iterations. Do not push a speculative E2E fix without a local pass.
3.other — Unrecognised Job
Stop and ask the user. Do not guess fixes for unfamiliar workflows (publish, release, deploy, bots, etc.).
Phase 4: Validate Locally, Then Push
Don't push until the relevant local checks are green. This is where most "blind push" cycles get caught.
-
Run the project's local validation gate before every push — the lint/format command discovered in Phase 0 step 6 (the same checks that gate every PR). If the project defines no such gate, skip this step. If it fails, fix and re-run before pushing.
-
Re-run the specific track's local check that failed in Phase 3 (the targeted test, hook, or build) to confirm the fix sticks.
-
Generated-artefact guard. If the project checks in generated artefacts (GraphQL schemas, OpenAPI clients, generated SDK types, protobufs) and the diff touches their sources, regenerate using the project's documented commands before committing — CI commonly fails on stale generated files.
-
Stage and commit the fix. One commit per iteration, with a clear message following the repo's commit conventions (check
git log --oneline -20):git add <specific files> git commit -m "ci: fix <short description of what failed and what changed>"Append the commit SHA to
fix_commits. Avoidgit add .— be specific, and never stage anything that looks like a secret or credential. -
Push to origin (no force-push; these are new commits, not history rewrites):
git push origin <branch> -
Increment
iteration. Record inattempt_log[iteration]:{ fix_summary: <one line>, files_changed: <list>, commit_sha: <new sha>, reproduced_locally: <true|false>, local_checks_passed: <true|false>, } -
If
iteration >= max_iterations: go to Phase 6 (revert). Otherwise return to Phase 1 to wait for the new CI run.
Phase 5: Success — Report and Stop
-
Confirm one more time that all checks for the current SHA are green:
gh pr checks <pr_number> -
Produce the final report (see "Report Format" below) with
Result: GREEN after N iterations. -
Exit cleanly. Do not merge the PR — that is always the user's call.
Phase 6: Revert — All Five Attempts Failed
If 5 iterations completed without reaching green, the fix attempts are likely misdirected. Roll the branch back to its pre-skill state so the user can take over with a clean slate.
-
Confirm the revert plan with the user before executing. Show:
- The fix commits that will be removed (
git log --oneline <baseline_commit>..HEAD). - The baseline commit they will be reset to.
- The fact that this is a force-push.
- A summary of what each iteration tried (from
attempt_log).
Wait for explicit user approval. If the user wants to keep one or more of the fix commits, abort the revert and hand back to them with a written summary.
- The fix commits that will be removed (
-
After approval, reset the local branch:
git reset --hard <baseline_commit> -
Force-push with lease so we don't clobber any commits the user may have pushed in parallel:
git push --force-with-lease=<branch>:<fix_commits[-1]> origin <branch>The
--force-with-lease=<ref>:<expected_oid>form ensures the push only succeeds if the remote tip is exactly the last commit this skill pushed. If the lease check fails, STOP — the user pushed something concurrently and the revert needs human review. -
Produce the final report with
Result: NOT FIXED — reverted after 5 iterations.
Report Format
Always produce this report at the end, regardless of outcome.
## PR Monitor Summary
**PR:** #<pr_number> — <title> (`<branch>`)
**Result:** GREEN after N iterations / NOT FIXED — reverted after 5 iterations
**Baseline commit:** `<short sha>`
**Final commit:** `<short sha>` (or "reverted to baseline")
### Iterations
#### Iteration 1
- **Failing job:** <name>
- **Failure summary:** <key log line(s)>
- **Reproduced locally:** yes / no (<reason if no>)
- **Fix applied:** <one-line description>
- **Files changed:** <list>
- **Local checks:** passed / skipped / failed
- **CI outcome after push:** green / still failing on <jobs>
#### Iteration 2
...
### CI-only failures (if any)
<List any failures that could not be reproduced locally and the evidence
gathered before pushing speculatively.>
### Current State
- Branch: `<branch>` at `<final sha>`
- Uncommitted changes: <yes/no — `git status --short`>
- Reverted: yes / no
### Recommendations
<If NOT FIXED: which iteration came closest, what hypotheses remain
untested, what a human should look at next.>
<If GREEN: any follow-up — flaky-looking failures, related tests to watch,
docs to update.>
Important Guidelines
Shortened here. Read the whole file on GitHub.
Signals
- GitHub stars
- 516
- Forks
- 59
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
monitoring-pull-requests- Source
- github.com/opsmill/infrahub