qodoloop · diff

v1.1 to v1.2

79 added, 21 removed. Audit A to A.

---
name: qodoloop
description: >
Iteratively drives a GitHub PR to zero unresolved Qodo (qodo-code-review[bot]) findings. Reads
each finding's own "Agent Prompt" (Qodo hands you a ready-made fix spec per issue), applies the
fix, replies to the thread explaining what was done, resolves it, pushes, and waits for Qodo's
next pass. Repeats until clean or a real blocker needs a human. Use when the user wants a PR fully
addressed against Qodo's review, or says "run qodoloop" / "satisfy Qodo" / "resolve Qodo comments".
compatibility: Requires git and gh (GitHub CLI) authenticated, with Qodo Merge installed on the repo.
metadata:
- version: "1.1"
+ version: "1.2"
allowed-tools: Bash(gh:*) Bash(git:*)
---
# Qodoloop
Fix every actionable Qodo finding on a PR, answer each thread, resolve it, and keep going until Qodo has nothing left to say (or a finding needs a human call).
## Inputs
- **PR number** (optional): detect from the current branch if not given.
- **Include optional findings** (optional, default off): Qodo tags each finding "Review recommended" (actionable) or "Optional" (informational/lower-confidence). By default only "Review recommended" findings count toward done; pass `--include-optional` to also fix and resolve the optional ones.
## How Qodo actually posts (verified against a live install: don't assume otherwise)
Qodo runs as `qodo-code-review[bot]` and posts **two independent things** per review pass, not one:
1. A rollup **issue comment** titled `<h3>Code Review by Qodo</h3>`: human-readable, all findings in one place. A separate `<h3>PR Summary by Qodo</h3>` comment is a plain description, not a review: ignore it. While running, Qodo posts `<h3>Qodo is busy working</h3>` as its own comment (not an edit of a prior one).
2. A **formal PR review** (`state: COMMENTED`) carrying one **inline review comment per finding**, each a real, resolvable GitHub review thread at the finding's file/line: same machinery as any human inline comment.
**Use the inline threads as the source of truth.** They carry `isResolved` state natively (fetch via GraphQL, see `references/graphql-queries.md`), so "done" is answerable directly instead of re-parsing the rollup's HTML on every iteration. Each thread's body contains a `<details><summary>Agent Prompt</summary>` (or `**Agent Prompt**`) section with a ready-made fix spec, and a severity badge image whose `alt` text is `Remediation recommended` or `Informational` (recommended ↔ "Review recommended", informational ↔ "Optional").
- Qodo has **no check run to poll**: there is nothing in `gh pr checks` to wait on. Completion means a new `qodo-code-review[bot]` comment/review appears after your push; poll comments, not checks.
+ Qodo has **no check run to poll**: there is nothing in `gh pr checks` to wait on. Completion means a new `qodo-code-review[bot]` comment/review appears after your push; `scripts/pr-review-state.sh` detects it.
**Rate limit is a real terminal state, not a bug.** A completed-looking comment reading "Qodo reviews are paused" / "you've reached your PR review limit" means Qodo will not review again this cycle: stop and report it, don't loop waiting for something that isn't coming.
+ ## Review state comes from the PR, not from memory
+
+ `scripts/pr-review-state.sh` reads the PR and prints one JSON object: the current head, whether
+ Qodo has acted on that head (a review of it, or any comment after it was pushed), and every
+ unresolved review thread with its author. Every "is there work / are we done" decision in this
+ loop comes from its exit code, never from what you remember resolving. The script lives in this
+ skill's directory, `SKILL_ROOT`: the directory holding this `SKILL.md` (Claude Code prints it as the
+ skill's base directory when the skill loads; in Codex, derive it from the selected `SKILL.md` path).
+
+ | Exit | `verdict` | Meaning | Do |
+ |------|------------|---------------------------------------------------------------------|--------------------------------------|
+ | 0 | `clean` | Qodo acted on the current head and no bot thread is unresolved | exit check: done |
+ | 10 | `findings` | unresolved threads from Qodo **or any other review bot** | run an iteration |
+ | 20 | `pending` | Qodo has not acted on the current head yet | trigger if needed, then wait |
+ | 30 | `limited` | a rate-limit or paused notice from Qodo after the current head | stop, report `blocked: rate-limited` |
+
+ Resolving the last thread does not make a PR clean: Qodo has not reviewed your push yet. Earlier
+ versions of this loop exited at exactly that moment, while the next round of comments was still on
+ its way. Human threads are listed in `.unresolved` (`is_bot: false`) and counted in `.counts.humans`;
+ they never block `clean`, and this loop never resolves them.
+
## Instructions
### 1. Identify the PR
```bash
gh pr view --json number,headRefName,headRefOid -q '{number, branch: .headRefName, sha: .headRefOid}'
```
Check out the branch if not already on it, **then require a clean working
tree** (`git status --porcelain` empty) before touching anything:
```bash
[ -z "$(git status --porcelain)" ] || { echo "working tree not clean, refusing to start: would risk committing unrelated changes"; exit 1; }
```
This is the precondition step E's scoped `git add <file>` depends on: staging
only the files a fix touched is safe *because* the tree started clean, so
nothing else could be sitting in those files. Skip this check and a file
with pre-existing local edits will have those edits swept into the fix
commit right along with it: the exact failure `git add -A` had, just
narrower.
### 2. Loop (max 5 iterations)
- #### A. Wait for a review of the current head
-
- On **iteration 1**, if there is nothing to push yet (you haven't fixed anything in this loop), don't wait for a brand-new review: use whatever Qodo has already posted as your starting point and go straight to step B. Otherwise:
-
- ```bash
- SINCE=$(date -u +%Y-%m-%dT%H:%M:%SZ)
- git push
- ```
+ #### A. Read the PR state; wait for Qodo's review of the current head
- Poll (every ~10s, timeout ~5min), fetching **every page** of comments, not just the first:
+ One read answers both "is there work?" and "has Qodo seen my last push?". `SINCE` is the
+ timestamp you took right before your last push (step E); leave it empty on iteration 1. Right
+ after a push, add `--wait 600` straight away: the wait ends only once the bot has acted on the new
+ head, so threads left open on purpose cannot make the push look reviewed.
```bash
- gh api --paginate "repos/{owner}/{repo}/issues/<PR_NUMBER>/comments?per_page=100" | jq -s '
- add | map(select(.user.login == "qodo-code-review[bot]")) | sort_by(.created_at)'
+ "$SKILL_ROOT/scripts/pr-review-state.sh" --repo OWNER/REPO --pr <PR> --bot qodo-code-review --since "$SINCE"
```
- From the comments created **after `$SINCE`** (ignore anything older: that's leftover from a prior push, not this one):
- - Any of them contains "review limit" or "paused" → **stop the loop now**, go to Report with `blocked: rate-limited`.
- - The latest one starts with `<h3>Code Review by Qodo</h3>` → proceed to step B.
- - Otherwise (nothing yet, or only "Qodo is busy working") → sleep 10s and poll again, up to the 5-minute timeout.
+ - Exit `10` (`findings`): go to B. On iteration 1 this is whatever Qodo (or another bot)
+ already posted.
+ - Exit `20` (`pending`): Qodo reviews new pushes on its own, so there is nothing to post. Then wait with the same command plus `--wait 600`. Run the
+ wait in the background where the host allows it (Claude Code: Bash `run_in_background`; you are
+ re-invoked when it exits) so ten minutes of polling never sits in the conversation. Still `20`
+ after the wait: say so out loud, check the PR for a Qodo notice explaining the silence, and wait once more. Still `20` after that: stop and
+ report the timeout honestly; never claim success.
+ - Exit `0` (`clean`): go to the exit check. A `--wait` call returns `clean` only after a settle
+ re-read 60 seconds later, so a comment landing right behind the review is still caught. A bare
+ read (no `--wait`) that says `clean` is good enough only when you have not pushed since Qodo's
+ last review.
+ - Exit `30` (`limited`): stop now and report `blocked: rate-limited`; nothing more is coming.
+ - Exit `1`: the read itself failed (often a transient GitHub 502). Retry once; if it fails again,
+ stop and report it. An unknown state is not a clean one.
#### B. Fetch unresolved findings
- Run the paginated GraphQL query in `references/graphql-queries.md` (`unresolvedQodoThreads`): **follow `pageInfo.hasNextPage`/`endCursor` until it's exhausted**; a PR with more than 100 Qodo threads silently loses every finding past the first page otherwise. For each unresolved thread from `qodo-code-review[bot]`: parse the finding title, severity (recommended/optional), file/line, and the **Agent Prompt** block.
+ The working set is every unresolved thread in the state JSON with `is_bot: true`: Qodo's
+ own (`is_target: true`) **and any other review bot's** (Greptile, for example). A
+ fix for one bot often draws a comment from another, and leaving those open is how a "finished" loop
+ leaves a PR that is not clean. Never act on a human's thread (`is_bot: false`); list it in the report.
+ Qodo's threads are `.unresolved[] | select(.is_target)` in step A's state JSON (the script already follows pagination). For each, read the full comment (`gh api repos/{owner}/{repo}/pulls/comments/<comment_id>`) and parse the finding title, severity (recommended/optional), file/line, and the **Agent Prompt** block.
+
Build the working set: all `recommended` threads, plus `optional` ones too if `--include-optional`.
+ Threads from other bots come from the state JSON (`.unresolved[] | select(.is_bot and (.is_target | not))`).
+ They carry no ready-made fix prompt: read the full comment
+ (`gh api repos/{owner}/{repo}/pulls/comments/<comment_id>`), verify its claim against the real
+ code, and handle it like any Qodo finding: fix it, or reply with the evidence for not fixing it.
+
Mark each thread as seen with an 👀 reaction on its first comment (`databaseId` from the query), so
the bot and a watching human can tell it is being worked. GitHub's reaction set is fixed; `eyes` is
the only "looking at it" value:
```bash
gh api --method POST repos/{owner}/{repo}/pulls/comments/<DATABASE_ID>/reactions -f content=eyes
```
#### C. Check exit conditions
- Stop if the working set is empty, or max iterations reached.
+ Stop when step A's read exited `0` (`clean`) after your last push (a `--wait` read, which settles),
+ or exited `30` (`limited`), or the iteration cap is reached. An empty working set on its own is not
+ done: with no review of the current head yet, it only means Qodo has not answered your push.
+ Two kinds of thread stay open by design, so for Qodo `findings` can still mean done: a finding you
+ **blocked** (replied, deliberately left unresolved) and, without `--include-optional`, an
+ **Optional** finding outside the working set. Stop as done when Qodo has acted on the current head
+ (`.bots[0].acted_on_head` is `true` in a `--wait` read) and every open bot thread is one of those
+ two. Never re-work a thread already in your blocked ledger.
+
#### D. Fix each finding
For each thread in the working set, in order: read the Agent Prompt in full: it already names the issue, the context, the fix focus files/lines, and often the suggested fix. Apply it. Do not go beyond its stated scope. Track two lists as you go: **fixed** (thread id + the exact files you touched for it) and **blocked** (thread id + why: the prompt's suggestion was wrong, unsafe, or needs a product decision). Do not force a fix into the blocked list; do not resolve anything yet.
#### E. Commit and push
If **fixed** is empty, skip straight to F (nothing to commit). Otherwise, stage **only the files you touched in D**: never `git add -A`, which can sweep in unrelated local changes or secrets that have nothing to do with this loop:
```bash
git add <files touched by this iteration's fixes>
git commit -m "address Qodo review feedback (qodoloop iteration N)"
+ SINCE=$(date -u +%Y-%m-%dT%H:%M:%SZ) # step A reads bot activity after this
git push
```
Confirm the push actually succeeded before moving on: a resolved thread whose fix never made it to the branch is worse than an unresolved one.
#### F. Answer each finding
This is the "answer to the comments" half of the job, not optional cleanup, and it only runs **after** E's push is confirmed durable, so a thread is never marked resolved while its fix still only exists locally:
- **Fixed** findings: reply with one or two sentences (what you changed and why), **then** resolve:
```bash
gh api graphql -f query='
mutation($threadId: ID!, $body: String!) {
addPullRequestReviewThreadReply(input: {pullRequestReviewThreadId: $threadId, body: $body}) {
comment { id }
}
}' -f threadId="$THREAD_ID" -f body="$REPLY_TEXT"
```
Pass the thread id and reply text as GraphQL **variables** (`-f threadId=... -f body=...`), never interpolated into the query string itself: a reply containing a quote, backtick, or newline breaks (or worse, injects into) a hand-built query. Then resolve with `resolveReviewThread` (see references file).
- **Blocked** findings: reply explaining exactly why (false positive / needs a human call), but **do not resolve**: leave the thread open. A blocked finding that gets silently resolved is a false "done".
Then record each disposition as a reaction on the thread's first comment: the learning signal, and
a visible marker for a human scanning the PR. 👍 (`+1`) for a fixed finding, 👎 (`-1`) for a
blocked/rebutted one. Reply first, react, then resolve: a reaction never replaces the reply.
```bash
gh api --method POST repos/{owner}/{repo}/pulls/comments/<DATABASE_ID>/reactions -f content=+1
# blocked/rebutted finding:
gh api --method POST repos/{owner}/{repo}/pulls/comments/<DATABASE_ID>/reactions -f content=-1
```
Go back to step A.
+ ### Keep the context small; resume instead of restarting
+
+ - Carry only three things between iterations: the iteration counter, the latest state JSON, and a
+ ledger of each thread you handled (fixed with its commit, rebutted, or blocked). Re-read files
+ fresh each iteration; do not re-paste earlier rounds' comment bodies.
+ - All loop state lives on the PR, so the loop is resumable. If the session has grown long or slow,
+ or a bot commented after the loop ended, run this skill again in a fresh session: step A picks up
+ from the PR as it is now. Starting fresh is cheaper than dragging a long session through more
+ rounds.
+ - After reporting done, if the host can schedule a follow-up (Claude Code: `/loop` or a scheduled
+ wakeup), offer one re-check about 20 minutes later for bots that comment late.
+
### 3. Report
| Field | Value |
|--------------------|-------|
| Iterations | N |
| Findings resolved | N |
| Findings blocked | N (with reasons, left unresolved) |
| Remaining | N (if any) |
- | Blocked | rate-limited / human-intervention / max-iterations / none |
+ | Human threads | N (open, listed; never resolved by this loop) |
+ | Blocked | rate-limited / timeout / human-intervention / max-iterations / none |
```text
Qodoloop complete.
Iterations: 2
Resolved: 4 (fixed, replied + resolved)
Blocked: 1 (needs a human call, replied, left unresolved)
Remaining: 0
```
## References
- - `references/graphql-queries.md`: the exact `unresolvedQodoThreads` query and the `resolveReviewThread` / `addPullRequestReviewThreadReply` mutations, with field notes.
+ - `references/graphql-queries.md`: the `resolveReviewThread` / `addPullRequestReviewThreadReply` mutations, with field notes. Its `unresolvedQodoThreads` query is superseded by `scripts/pr-review-state.sh` for finding threads.