From 17ece55dcdc2d2f166d0f74cfaa74ce120918c14 Mon Sep 17 00:00:00 2001 From: lionuncle Date: Wed, 15 Apr 2026 17:23:13 +0500 Subject: [PATCH 1/2] Fix re-review loop: skip rpr's own previous reviews rpr was feeding its own prior reviews back as "previous feedback", causing Claude to re-comment on its own concerns indefinitely instead of approving. Now tags all outgoing reviews with an invisible HTML marker () and filters them out when building context for subsequent runs. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/rpr/cli.py | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/src/rpr/cli.py b/src/rpr/cli.py index 04ec7aa..8d09838 100755 --- a/src/rpr/cli.py +++ b/src/rpr/cli.py @@ -389,6 +389,9 @@ def get_previous_reviews(pr_number: int, repo: str | None) -> tuple[list, list]: return reviews, comments +RPR_MARKER = "" + + def format_previous_reviews(reviews: list, comments: list) -> str: """Format previous reviews into a prompt section. Returns '' if none.""" if not reviews and not comments: @@ -398,15 +401,25 @@ def format_previous_reviews(reviews: list, comments: list) -> str: for r in reviews: body = (r.get("body") or "").strip() - if not body: + if not body or RPR_MARKER in body: continue user = r.get("user", {}).get("login", "unknown") state = r.get("state", "COMMENTED") parts.append(f"[{user} — {state}]\n{body}") + # Collect review IDs that came from rpr so we can skip their inline comments + rpr_review_ids = { + r.get("id") + for r in reviews + if RPR_MARKER in (r.get("body") or "") + } + for c in comments: body = (c.get("body") or "").strip() - if not body: + if not body or RPR_MARKER in body: + continue + # Skip inline comments that belong to an rpr review + if c.get("pull_request_review_id") in rpr_review_ids: continue user = c.get("user", {}).get("login", "unknown") path = c.get("path", "?") @@ -432,7 +445,7 @@ def format_previous_reviews(reviews: list, comments: list) -> str: def post_review_comment(pr_number: int, body: str, repo: str | None): """Post a simple comment on the PR (not an inline review).""" - run_gh(["pr", "comment", str(pr_number), "--body", body], repo) + run_gh(["pr", "comment", str(pr_number), "--body", body + "\n" + RPR_MARKER], repo) def post_review( @@ -446,8 +459,8 @@ def post_review( Submit a pull request review with optional inline comments. event: COMMENT | APPROVE | REQUEST_CHANGES """ - # Build the review payload - payload = {"event": event, "body": body} + # Build the review payload (marker lets future runs skip our own reviews) + payload = {"event": event, "body": body + "\n" + RPR_MARKER} if comments: payload["comments"] = [] From 049033a153920be432aeee7cbe9c05f8e3b277f7 Mon Sep 17 00:00:00 2001 From: lionuncle Date: Wed, 15 Apr 2026 17:32:03 +0500 Subject: [PATCH 2/2] Add thread reply processing: verify fixes and resolve threads When rpr runs, it now checks its own previous review threads for human replies. If someone says "fixed", rpr fetches the current code at that location and asks Claude to verify the fix before replying and resolving. If someone provides a technical explanation, rpr acknowledges and resolves. Uses GraphQL to fetch thread IDs and resolve them. All rpr replies are tagged with the marker so they're excluded from future context, preventing the re-comment loop. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/rpr/cli.py | 316 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 316 insertions(+) diff --git a/src/rpr/cli.py b/src/rpr/cli.py index 8d09838..727687d 100755 --- a/src/rpr/cli.py +++ b/src/rpr/cli.py @@ -957,6 +957,315 @@ def parse_review(raw: str) -> dict: return {"summary": raw, "comments": []} +# --------------------------------------------------------------------------- +# Thread processing — reply to & resolve rpr's own review threads +# --------------------------------------------------------------------------- + + +def _get_repo_nwo(repo: str | None) -> tuple[str, str] | None: + """Get (owner, name) from --repo flag or current repo.""" + if repo: + parts = repo.split("/", 1) + if len(parts) == 2: + return parts[0], parts[1] + return None + raw = try_run_gh( + ["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"], + None, + ) + if raw: + parts = raw.strip().split("/", 1) + if len(parts) == 2: + return parts[0], parts[1] + return None + + +def _fetch_rpr_threads(pr_number: int, repo: str | None) -> tuple[list, str]: + """Fetch unresolved rpr review threads that have human replies. + + Returns (threads, head_oid). + """ + nwo = _get_repo_nwo(repo) + if not nwo: + return [], "" + owner, name = nwo + + query = ( + "query($owner: String!, $name: String!, $pr: Int!) {" + " repository(owner: $owner, name: $name) {" + " pullRequest(number: $pr) {" + " headRefOid" + " reviewThreads(first: 100) {" + " nodes {" + " id isResolved path line" + " comments(first: 50) {" + " nodes {" + " databaseId body" + " author { login }" + " pullRequestReview { body }" + " }" + " }" + " }" + " }" + " }" + " }" + "}" + ) + + result = subprocess.run( + [ + "gh", "api", "graphql", + "-f", f"query={query}", + "-F", f"owner={owner}", + "-F", f"name={name}", + "-F", f"pr={pr_number}", + ], + capture_output=True, + text=True, + ) + if result.returncode != 0: + return [], "" + + data = json.loads(result.stdout) + pr_data = ( + data.get("data", {}) + .get("repository", {}) + .get("pullRequest", {}) + ) + all_threads = pr_data.get("reviewThreads", {}).get("nodes", []) + head_oid = pr_data.get("headRefOid", "HEAD") + + pending = [] + for t in all_threads: + if t.get("isResolved"): + continue + comments = t.get("comments", {}).get("nodes", []) + if len(comments) < 2: + continue + + original = comments[0] + review_body = (original.get("pullRequestReview") or {}).get("body", "") + if RPR_MARKER not in review_body: + continue + + human_replies = [ + c for c in comments[1:] + if RPR_MARKER not in (c.get("body") or "") + ] + if not human_replies: + continue + + pending.append({ + "thread_id": t["id"], + "path": t.get("path", ""), + "line": t.get("line"), + "original_comment": original.get("body", ""), + "original_comment_id": original.get("databaseId"), + "replies": [ + { + "author": (r.get("author") or {}).get("login", "?"), + "body": r.get("body", ""), + } + for r in human_replies + ], + }) + + return pending, head_oid + + +def _fetch_files(paths: list[str], repo: str | None, ref: str) -> dict: + """Fetch file contents for the given paths. Returns {path: [lines]}.""" + import base64 + + cache: dict[str, list[str]] = {} + for path in set(paths): + raw = try_run_gh( + [ + "api", + f"repos/{{owner}}/{{repo}}/contents/{path}", + "-f", f"ref={ref}", + "--jq", ".content", + ], + repo, + ) + if not raw: + continue + try: + text = base64.b64decode(raw.strip()).decode("utf-8", errors="replace") + cache[path] = text.splitlines() + except Exception: + pass + return cache + + +def _file_snippet( + path: str, line: int | None, file_cache: dict, context: int = 5, +) -> str: + """Extract a code snippet around a line from cached file content.""" + if not line or path not in file_cache: + return "(code not available)" + lines = file_cache[path] + start = max(0, line - context - 1) + end = min(len(lines), line + context) + parts = [] + for i in range(start, end): + marker = ">" if i == line - 1 else " " + parts.append(f"{marker} {i + 1}: {lines[i]}") + return "\n".join(parts) + + +def _reply_in_thread( + pr_number: int, comment_id: int, body: str, repo: str | None, +): + """Post a reply in a review comment thread.""" + payload = json.dumps({"body": body + "\n" + RPR_MARKER}) + cmd = _gh_cmd( + [ + "api", + f"repos/{{owner}}/{{repo}}/pulls/{pr_number}/comments/{comment_id}/replies", + "--method", "POST", + "--input", "-", + ], + repo, + ) + subprocess.run(cmd, input=payload, capture_output=True, text=True) + + +def _resolve_thread(thread_node_id: str): + """Resolve a review thread via GraphQL.""" + mutation = ( + "mutation($threadId: ID!) {" + " resolveReviewThread(input: {threadId: $threadId}) {" + " thread { isResolved }" + " }" + "}" + ) + subprocess.run( + [ + "gh", "api", "graphql", + "-f", f"query={mutation}", + "-F", f"threadId={thread_node_id}", + ], + capture_output=True, + text=True, + ) + + +THREAD_SYSTEM_PROMPT = ( + "You are a senior developer following up on your own code review comments. " + "Be natural, brief, and direct — like a real developer replying in a PR thread." +) + +THREAD_USER_PROMPT = """\ +You previously left inline review comments on a PR. Developers have replied \ +to some of them. For each thread, decide whether to resolve it or keep it open. + +{threads_text} + +Return a JSON array (no markdown fences, no preamble): +[ + {{ + "thread_index": 0, + "action": "resolve" | "keep_open", + "reply": "Brief reply (1 sentence, natural dev tone)" + }} +] + +Guidelines: +- Developer says "fixed"/"done"/"addressed" AND current code shows the fix \ +→ resolve with brief confirmation ("Looks good now." / "Nice, fixed.") +- Developer gives a valid technical explanation for why the code is correct \ +→ resolve with acknowledgment ("Fair point." / "Makes sense, my bad.") +- Developer says "fixed" but the code still has the same issue → keep_open, \ +explain what's still wrong (1-2 sentences) +- If unclear, lean toward resolving — don't be a blocker on ambiguous threads +- NEVER start with "I" — vary your openings""" + + +def _parse_thread_decisions(raw: str) -> list: + """Parse Claude's thread-processing response as a JSON array.""" + text = raw.strip() + if text.startswith("```"): + lines = text.split("\n") + if lines[0].startswith("```"): + lines = lines[1:] + if lines and lines[-1].strip() == "```": + lines = lines[:-1] + text = "\n".join(lines) + try: + return json.loads(text) + except json.JSONDecodeError: + start = text.find("[") + end = text.rfind("]") + 1 + if start >= 0 and end > start: + try: + return json.loads(text[start:end]) + except json.JSONDecodeError: + pass + return [] + + +def process_rpr_threads(pr_number: int, repo: str | None, config: dict) -> int: + """Check replies to rpr's review threads, verify, reply, and resolve. + + Returns the number of threads resolved. + """ + pending, head_oid = _fetch_rpr_threads(pr_number, repo) + if not pending: + return 0 + + print(f" Found {len(pending)} thread(s) with replies", file=sys.stderr) + + # Fetch current file contents for verification + paths = [t["path"] for t in pending if t.get("path")] + file_cache = _fetch_files(paths, repo, head_oid) + + # Build the prompt + thread_parts = [] + for i, t in enumerate(pending): + snippet = _file_snippet(t["path"], t["line"], file_cache) + replies = "\n".join( + f" {r['author']}: {r['body']}" for r in t["replies"] + ) + thread_parts.append( + f"Thread {i}:\n" + f" File: {t['path']}, Line: {t['line']}\n" + f" Your comment: {t['original_comment']}\n" + f" Replies:\n{replies}\n" + f" Current code:\n{snippet}" + ) + + threads_text = "\n\n".join(thread_parts) + prompt = THREAD_USER_PROMPT.format(threads_text=threads_text) + + print(" Verifying with Claude...", file=sys.stderr) + raw = call_claude(prompt, THREAD_SYSTEM_PROMPT, config) + + decisions = _parse_thread_decisions(raw) + if not decisions: + return 0 + + resolved = 0 + for d in decisions: + idx = d.get("thread_index", -1) + if idx < 0 or idx >= len(pending): + continue + + thread = pending[idx] + reply_text = d.get("reply", "") + + if reply_text: + _reply_in_thread( + pr_number, thread["original_comment_id"], reply_text, repo, + ) + + if d.get("action") == "resolve": + _resolve_thread(thread["thread_id"]) + resolved += 1 + + return resolved + + # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- @@ -1044,6 +1353,13 @@ def _bg_update_check(): nc = len([c for c in prev_comments if (c.get("body") or "").strip()]) print(f" Found {nr} prior reviews, {nc} inline comments", file=sys.stderr) + # 4b. Process replies to rpr's previous review threads + if not args.dry_run: + print("💬 Checking thread replies...", file=sys.stderr) + threads_resolved = process_rpr_threads(args.pr_number, args.repo, config) + if threads_resolved: + print(f" ✅ Resolved {threads_resolved} thread(s)", file=sys.stderr) + # 5. Build prompt and call Claude depth_label = REVIEW_DEPTHS[args.depth]["label"] system, user = build_prompt(pr_info, diff, valid_lines, previous_section, args.depth)