pr-fix-loop
Operational skill for driving multiple GitHub PRs to green in parallel. One invocation is one loop pass. For every PR in the list it:
- Takes the latest base into the branch (rebase; parent PR branch for stacks, else the default branch)
- Fetches CI status, classifies each failing check, and delegates to a
fix-ci-*skill - Handles unresolved review comments (fetch → fix → commit → push → resolve)
- Chains rebases when PRs are stacked (PR Y's base = PR X's branch)
This skill defines a single pass. To repeat on a schedule, drive it with a loop runner —
e.g. /loop 10m /pr-fix-loop <PR-list> (a cadence at which CI advances roughly one step per pass
and the prompt cache is not blown every time). When you observe N consecutive no-change passes
(default 5), emit the exit signal — see Step 7.
Project assumption. Examples use Kotlin/Gradle task names (
ktlintCheck,apiCheck,compileKotlin,jvmTest) because that is where this skill was hardened, but nothing here is Kotlin-specific. Substitute your project's lint / API-check / build / test task names; the classification heuristics inreferences/failure-classification.mdare keyed on generic job/log patterns.
Scripts (the deterministic core — run as-is)
The deterministic parts of every pass are implemented as executable scripts in
scripts/. Run them via ${CLAUDE_SKILL_DIR}/scripts/<name> (in Claude Code
${CLAUDE_SKILL_DIR} is the directory containing this SKILL.md; otherwise substitute the
skill's install path). Do not read the scripts to re-derive their commands, do not modify
them, and do not re-implement their logic inline — execute them and read their JSON.
They are the single source of truth for pagination, classification patterns, rebase mechanics,
and streak persistence; hand-transcribing any of it is how data gets silently dropped.
| Script | Step | One line |
|---|---|---|
fetch-pr-state.sh <pr...> |
3 | Preflight + batch-fetch per-PR state (checks, threads, comments — fully paginated) |
classify-failure.sh <runId> <jobId> |
4 | Fetch one failing job's log and classify it (transient → lint → binary → test → build → unknown) |
rebase-pass.sh <pr...> |
1 & 6 | Rebase every PR onto its own latest base, stacks in topological order |
resolve-thread.sh <threadId> [commit] |
5 | Resolve one inline review thread |
mark-comment-handled.sh <commentId> <commit...> |
5 | Wrap one issue comment in the handled marker |
streak.sh <no-change|changed> |
7 | Persist the no-change streak; report terminate |
Shared contract: stdout is one line of JSON (multi-PR scripts emit a JSON array); progress and errors go to stderr; on failure the script exits non-zero with a "what / why / how to fix" message — relay that message to the user and stop the pass (or defer the PR) as it says. All scripts are idempotent, so re-running after a partial pass is safe.
With the scripts in charge of the mechanics, your job per pass collapses to: run scripts →
read JSON → delegate to fix-ci-* / write the fix → commit & push → resolve/mark → report.
Prerequisites
git, gh (GitHub CLI), and jq must work locally (the scripts check all three and verify
gh auth status; on failure they exit with the fix instruction — report it to the user and
abort the pass). See references/operations.md for the operational
contract (dirty-worktree abort, log-fetch timing, rerun blocking, commit granularity).
Input
- A list of PR numbers (one or more, space-separated). e.g.
179 180 181 - If omitted, derive the single PR for the current branch via
gh pr view --json number
Branch name, base branch, and stack relationships are read every pass by the scripts — never held as a static mapping in the input, so the loop follows reorders / rebases / branch renames automatically.
Workflow (one pass)
Run the following for every PR in one pass. Do not skip PRs (if one is fully in-progress and nothing can be done, still advance the others). Steps are a natural-number sequence.
Step 1: Rebase the latest base into each PR branch
"${CLAUDE_SKILL_DIR}/scripts/rebase-pass.sh" $PR_LIST
The script fetches origin, reads each PR's branch/base, orders stacks topologically
(parent before child), and per PR: matches the local branch to remote, rebases onto the latest
of its own base only when behind, and pushes with --force-with-lease. On conflict it
aborts the rebase and defers — never auto-merges. Read the JSON array:
action: "clean"— already up to date, nothing pushedaction: "rebased"— rebased and pushedaction: "conflict-deferred"— seedetail; report "deferred to next pass" and move on
This prevents a PR branch from going stale when its base moves under it (a sibling PR merged this session, a dependabot bump, another collaborator's merge).
Step 2: Commit tidy-up (optional; fires at ≥ 10 commits ahead of base)
When a PR branch is ≥ 10 commits ahead of base, tidy the commits for reviewability. The shell
Claude Code runs in cannot complete interactive rebase (-i), so do everything non-interactively.
Detect blocks with these heuristics (top wins; leave non-matching commits untouched):
- Same conventional-commit prefix in a run (e.g. three consecutive
fix(ci): ...) → squash into one fixup!/squash!prefix → squash into the preceding commit (autosquash, done manually)- A run of small commits touching only the same file/file-set → squash into one
Implement via git reset --soft <block-parent> && git commit -m "...", working from the bottom up,
then git push --force-with-lease. Never lose a commit: back up first
(git update-ref refs/pr-fix-loop-backup/$pr/<ts> HEAD), and on any conflict / empty result restore
the backup and report "tidy failed, deferred". Don't re-squash the same PR within 24h (chatter
guard). If 10+ commits are all logically distinct, don't force it — report "no tidy candidates".
Ordering vs. stacks (Step 6): tidy parent → push parent → rebase child → tidy child.
Step 3: Fetch all PR state in one batch
"${CLAUDE_SKILL_DIR}/scripts/fetch-pr-state.sh" $PR_LIST
Preflight (gh auth, dirty-worktree abort, owner/repo derivation) is built in. The script pages through both comment kinds — inline review threads (GraphQL) and conversation-level issue comments (REST) — so nothing is silently dropped on busy PRs. Read the JSON array; per PR:
failingChecks: [{jobId, jobName, runId}]— input for Step 4inProgress— number of checks still running (for the report table)unresolvedThreads: [{id, path, line, firstComment}]— input for Step 5-AunhandledIssueComments: [{id, excerpt}]— input for Step 5-BstackParent— the parent PR number when this PR stacks on another listed PR, elsenull
Step 4: Classify failing checks and delegate
For every entry in a PR's failingChecks:
"${CLAUDE_SKILL_DIR}/scripts/classify-failure.sh" <runId> <jobId>
The script fetches the job log and applies the ordered heuristics of
references/failure-classification.md
(transient → lint → binary → test → build → unknown; the trailing > Task :xxx: FAILED task
name is the truth, the job name is display only). Read {kind, taskName, evidence, delegate, logTail} and act:
kind |
Your action |
|---|---|
transient |
gh run rerun <runId> --failed (rejected while the workflow is in-progress — defer to next pass) |
lint |
delegate to fix-ci-lint skill |
binary |
delegate to fix-ci-binary skill |
build |
delegate to fix-ci-build skill |
test |
delegate to fix-ci-test skill |
unknown |
your judgment — present logTail and the job URL to the user; do not attempt a blind fix |
The fix-ci-* names are a naming scheme, not a hard dependency. If the matching skill does
not exist in the current repo, summarize evidence + logTail and report to the user — do
not run off and hand-fix. Do each PR's fix on its own branch (git checkout <headRef>).
Step 5: Handle unresolved review / issue comments
Conventions in references/review-handling.md. In brief, for
each item from Step 3's JSON — fix → commit → push, then:
- inline review thread (
unresolvedThreads):"${CLAUDE_SKILL_DIR}/scripts/resolve-thread.sh" <threadId> [commitHash] - issue-level comment (
unhandledIssueComments; no native resolve on GitHub):"${CLAUDE_SKILL_DIR}/scripts/mark-comment-handled.sh" <commentId> <commitHash...>— wraps the body in the collapsible handled marker with the fixing commit(s) appended - Miss-prevention:
fetch-pr-state.shre-scans both kinds every pass; anything still listed counts the same asunresolved ≥ 1toward Step 7's "changed" verdict
Step 6: Stack rebase chain
rebase-pass.sh already processes stacks in topological order (Step 3's stackParent
shows the relationships). After pushing a fix commit to a parent PR during Steps 4–5,
run the script again to chain the children:
"${CLAUDE_SKILL_DIR}/scripts/rebase-pass.sh" $PR_LIST
It is idempotent — untouched PRs report clean, children of the pushed parent report
rebased, conflicts report conflict-deferred (give up for this pass and report — never
auto-merge).
Step 7: Termination check
Classify the pass as "no-change" or "changed":
- no-change = every PR has
failingChecks == [] && unresolvedThreads == [] && unhandledIssueComments == []and you pushed no new commit this pass - otherwise = changed
Then persist the verdict:
"${CLAUDE_SKILL_DIR}/scripts/streak.sh" no-change # or: changed
Read {streak, limit, terminate}. Any "changed" pass resets the streak; the streak survives
across passes (file under .local/tmp/), so a /loop driver stops cleanly. When terminate
is true (default limit 5), state "terminating" explicitly — the streak file is already
cleaned for the next invocation.
Always append (no-change streak: N/limit) to the end-of-pass report. Whether the user
stops /loop itself is their call, but the skill treats terminate: true as a
"nothing left to do" signal.
References
references/failure-classification.md— whatclassify-failure.shimplements: the ordered kinds, per-kind signals, and false-positive caveats (read to sanity-check verdicts and to judgeunknown)references/review-handling.md— Step 5 conventions: the handled-marker wrap, commit↔thread association, per-point commits, miss-preventionreferences/operations.md— operational contract: script preflight (auth / dirty worktree / owner-repo), log-fetch timing, rerun blocking, non-interactive rebase, cross-platformstat, commit granularity, pagination background
Output (end of each pass)
Close each pass with one short table (columns map straight onto fetch-pr-state.sh fields):
| PR | failures | in-progress | unresolved | action |
|-----|----------|-------------|------------|--------|
| 179 | 0 | 0 | 0 | (untouched — already green) |
| 180 | apiCheck | 1 | 0 | apiDump pushed, waiting on CI rerun |
| 181 | 0 | 5 | 0 | rebased #180 fix in, pushed |
The final line carries the current streak, e.g.:
no-change (streak 1/5) — recheck in 10 minchanged (CI in progress, streak reset) — recheck next passno-change (streak 5/5) — terminating
At terminate: true, state "terminating" on that line (the script has already removed the
streak file).