Skip to content

Commit 049033a

Browse files
lionuncleclaude
andcommitted
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 <!-- rpr --> marker so they're excluded from future context, preventing the re-comment loop. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 17ece55 commit 049033a

1 file changed

Lines changed: 316 additions & 0 deletions

File tree

src/rpr/cli.py

Lines changed: 316 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -957,6 +957,315 @@ def parse_review(raw: str) -> dict:
957957
return {"summary": raw, "comments": []}
958958

959959

960+
# ---------------------------------------------------------------------------
961+
# Thread processing — reply to & resolve rpr's own review threads
962+
# ---------------------------------------------------------------------------
963+
964+
965+
def _get_repo_nwo(repo: str | None) -> tuple[str, str] | None:
966+
"""Get (owner, name) from --repo flag or current repo."""
967+
if repo:
968+
parts = repo.split("/", 1)
969+
if len(parts) == 2:
970+
return parts[0], parts[1]
971+
return None
972+
raw = try_run_gh(
973+
["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"],
974+
None,
975+
)
976+
if raw:
977+
parts = raw.strip().split("/", 1)
978+
if len(parts) == 2:
979+
return parts[0], parts[1]
980+
return None
981+
982+
983+
def _fetch_rpr_threads(pr_number: int, repo: str | None) -> tuple[list, str]:
984+
"""Fetch unresolved rpr review threads that have human replies.
985+
986+
Returns (threads, head_oid).
987+
"""
988+
nwo = _get_repo_nwo(repo)
989+
if not nwo:
990+
return [], ""
991+
owner, name = nwo
992+
993+
query = (
994+
"query($owner: String!, $name: String!, $pr: Int!) {"
995+
" repository(owner: $owner, name: $name) {"
996+
" pullRequest(number: $pr) {"
997+
" headRefOid"
998+
" reviewThreads(first: 100) {"
999+
" nodes {"
1000+
" id isResolved path line"
1001+
" comments(first: 50) {"
1002+
" nodes {"
1003+
" databaseId body"
1004+
" author { login }"
1005+
" pullRequestReview { body }"
1006+
" }"
1007+
" }"
1008+
" }"
1009+
" }"
1010+
" }"
1011+
" }"
1012+
"}"
1013+
)
1014+
1015+
result = subprocess.run(
1016+
[
1017+
"gh", "api", "graphql",
1018+
"-f", f"query={query}",
1019+
"-F", f"owner={owner}",
1020+
"-F", f"name={name}",
1021+
"-F", f"pr={pr_number}",
1022+
],
1023+
capture_output=True,
1024+
text=True,
1025+
)
1026+
if result.returncode != 0:
1027+
return [], ""
1028+
1029+
data = json.loads(result.stdout)
1030+
pr_data = (
1031+
data.get("data", {})
1032+
.get("repository", {})
1033+
.get("pullRequest", {})
1034+
)
1035+
all_threads = pr_data.get("reviewThreads", {}).get("nodes", [])
1036+
head_oid = pr_data.get("headRefOid", "HEAD")
1037+
1038+
pending = []
1039+
for t in all_threads:
1040+
if t.get("isResolved"):
1041+
continue
1042+
comments = t.get("comments", {}).get("nodes", [])
1043+
if len(comments) < 2:
1044+
continue
1045+
1046+
original = comments[0]
1047+
review_body = (original.get("pullRequestReview") or {}).get("body", "")
1048+
if RPR_MARKER not in review_body:
1049+
continue
1050+
1051+
human_replies = [
1052+
c for c in comments[1:]
1053+
if RPR_MARKER not in (c.get("body") or "")
1054+
]
1055+
if not human_replies:
1056+
continue
1057+
1058+
pending.append({
1059+
"thread_id": t["id"],
1060+
"path": t.get("path", ""),
1061+
"line": t.get("line"),
1062+
"original_comment": original.get("body", ""),
1063+
"original_comment_id": original.get("databaseId"),
1064+
"replies": [
1065+
{
1066+
"author": (r.get("author") or {}).get("login", "?"),
1067+
"body": r.get("body", ""),
1068+
}
1069+
for r in human_replies
1070+
],
1071+
})
1072+
1073+
return pending, head_oid
1074+
1075+
1076+
def _fetch_files(paths: list[str], repo: str | None, ref: str) -> dict:
1077+
"""Fetch file contents for the given paths. Returns {path: [lines]}."""
1078+
import base64
1079+
1080+
cache: dict[str, list[str]] = {}
1081+
for path in set(paths):
1082+
raw = try_run_gh(
1083+
[
1084+
"api",
1085+
f"repos/{{owner}}/{{repo}}/contents/{path}",
1086+
"-f", f"ref={ref}",
1087+
"--jq", ".content",
1088+
],
1089+
repo,
1090+
)
1091+
if not raw:
1092+
continue
1093+
try:
1094+
text = base64.b64decode(raw.strip()).decode("utf-8", errors="replace")
1095+
cache[path] = text.splitlines()
1096+
except Exception:
1097+
pass
1098+
return cache
1099+
1100+
1101+
def _file_snippet(
1102+
path: str, line: int | None, file_cache: dict, context: int = 5,
1103+
) -> str:
1104+
"""Extract a code snippet around a line from cached file content."""
1105+
if not line or path not in file_cache:
1106+
return "(code not available)"
1107+
lines = file_cache[path]
1108+
start = max(0, line - context - 1)
1109+
end = min(len(lines), line + context)
1110+
parts = []
1111+
for i in range(start, end):
1112+
marker = ">" if i == line - 1 else " "
1113+
parts.append(f"{marker} {i + 1}: {lines[i]}")
1114+
return "\n".join(parts)
1115+
1116+
1117+
def _reply_in_thread(
1118+
pr_number: int, comment_id: int, body: str, repo: str | None,
1119+
):
1120+
"""Post a reply in a review comment thread."""
1121+
payload = json.dumps({"body": body + "\n" + RPR_MARKER})
1122+
cmd = _gh_cmd(
1123+
[
1124+
"api",
1125+
f"repos/{{owner}}/{{repo}}/pulls/{pr_number}/comments/{comment_id}/replies",
1126+
"--method", "POST",
1127+
"--input", "-",
1128+
],
1129+
repo,
1130+
)
1131+
subprocess.run(cmd, input=payload, capture_output=True, text=True)
1132+
1133+
1134+
def _resolve_thread(thread_node_id: str):
1135+
"""Resolve a review thread via GraphQL."""
1136+
mutation = (
1137+
"mutation($threadId: ID!) {"
1138+
" resolveReviewThread(input: {threadId: $threadId}) {"
1139+
" thread { isResolved }"
1140+
" }"
1141+
"}"
1142+
)
1143+
subprocess.run(
1144+
[
1145+
"gh", "api", "graphql",
1146+
"-f", f"query={mutation}",
1147+
"-F", f"threadId={thread_node_id}",
1148+
],
1149+
capture_output=True,
1150+
text=True,
1151+
)
1152+
1153+
1154+
THREAD_SYSTEM_PROMPT = (
1155+
"You are a senior developer following up on your own code review comments. "
1156+
"Be natural, brief, and direct — like a real developer replying in a PR thread."
1157+
)
1158+
1159+
THREAD_USER_PROMPT = """\
1160+
You previously left inline review comments on a PR. Developers have replied \
1161+
to some of them. For each thread, decide whether to resolve it or keep it open.
1162+
1163+
{threads_text}
1164+
1165+
Return a JSON array (no markdown fences, no preamble):
1166+
[
1167+
{{
1168+
"thread_index": 0,
1169+
"action": "resolve" | "keep_open",
1170+
"reply": "Brief reply (1 sentence, natural dev tone)"
1171+
}}
1172+
]
1173+
1174+
Guidelines:
1175+
- Developer says "fixed"/"done"/"addressed" AND current code shows the fix \
1176+
→ resolve with brief confirmation ("Looks good now." / "Nice, fixed.")
1177+
- Developer gives a valid technical explanation for why the code is correct \
1178+
→ resolve with acknowledgment ("Fair point." / "Makes sense, my bad.")
1179+
- Developer says "fixed" but the code still has the same issue → keep_open, \
1180+
explain what's still wrong (1-2 sentences)
1181+
- If unclear, lean toward resolving — don't be a blocker on ambiguous threads
1182+
- NEVER start with "I" — vary your openings"""
1183+
1184+
1185+
def _parse_thread_decisions(raw: str) -> list:
1186+
"""Parse Claude's thread-processing response as a JSON array."""
1187+
text = raw.strip()
1188+
if text.startswith("```"):
1189+
lines = text.split("\n")
1190+
if lines[0].startswith("```"):
1191+
lines = lines[1:]
1192+
if lines and lines[-1].strip() == "```":
1193+
lines = lines[:-1]
1194+
text = "\n".join(lines)
1195+
try:
1196+
return json.loads(text)
1197+
except json.JSONDecodeError:
1198+
start = text.find("[")
1199+
end = text.rfind("]") + 1
1200+
if start >= 0 and end > start:
1201+
try:
1202+
return json.loads(text[start:end])
1203+
except json.JSONDecodeError:
1204+
pass
1205+
return []
1206+
1207+
1208+
def process_rpr_threads(pr_number: int, repo: str | None, config: dict) -> int:
1209+
"""Check replies to rpr's review threads, verify, reply, and resolve.
1210+
1211+
Returns the number of threads resolved.
1212+
"""
1213+
pending, head_oid = _fetch_rpr_threads(pr_number, repo)
1214+
if not pending:
1215+
return 0
1216+
1217+
print(f" Found {len(pending)} thread(s) with replies", file=sys.stderr)
1218+
1219+
# Fetch current file contents for verification
1220+
paths = [t["path"] for t in pending if t.get("path")]
1221+
file_cache = _fetch_files(paths, repo, head_oid)
1222+
1223+
# Build the prompt
1224+
thread_parts = []
1225+
for i, t in enumerate(pending):
1226+
snippet = _file_snippet(t["path"], t["line"], file_cache)
1227+
replies = "\n".join(
1228+
f" {r['author']}: {r['body']}" for r in t["replies"]
1229+
)
1230+
thread_parts.append(
1231+
f"Thread {i}:\n"
1232+
f" File: {t['path']}, Line: {t['line']}\n"
1233+
f" Your comment: {t['original_comment']}\n"
1234+
f" Replies:\n{replies}\n"
1235+
f" Current code:\n{snippet}"
1236+
)
1237+
1238+
threads_text = "\n\n".join(thread_parts)
1239+
prompt = THREAD_USER_PROMPT.format(threads_text=threads_text)
1240+
1241+
print(" Verifying with Claude...", file=sys.stderr)
1242+
raw = call_claude(prompt, THREAD_SYSTEM_PROMPT, config)
1243+
1244+
decisions = _parse_thread_decisions(raw)
1245+
if not decisions:
1246+
return 0
1247+
1248+
resolved = 0
1249+
for d in decisions:
1250+
idx = d.get("thread_index", -1)
1251+
if idx < 0 or idx >= len(pending):
1252+
continue
1253+
1254+
thread = pending[idx]
1255+
reply_text = d.get("reply", "")
1256+
1257+
if reply_text:
1258+
_reply_in_thread(
1259+
pr_number, thread["original_comment_id"], reply_text, repo,
1260+
)
1261+
1262+
if d.get("action") == "resolve":
1263+
_resolve_thread(thread["thread_id"])
1264+
resolved += 1
1265+
1266+
return resolved
1267+
1268+
9601269
# ---------------------------------------------------------------------------
9611270
# Main
9621271
# ---------------------------------------------------------------------------
@@ -1044,6 +1353,13 @@ def _bg_update_check():
10441353
nc = len([c for c in prev_comments if (c.get("body") or "").strip()])
10451354
print(f" Found {nr} prior reviews, {nc} inline comments", file=sys.stderr)
10461355

1356+
# 4b. Process replies to rpr's previous review threads
1357+
if not args.dry_run:
1358+
print("💬 Checking thread replies...", file=sys.stderr)
1359+
threads_resolved = process_rpr_threads(args.pr_number, args.repo, config)
1360+
if threads_resolved:
1361+
print(f" ✅ Resolved {threads_resolved} thread(s)", file=sys.stderr)
1362+
10471363
# 5. Build prompt and call Claude
10481364
depth_label = REVIEW_DEPTHS[args.depth]["label"]
10491365
system, user = build_prompt(pr_info, diff, valid_lines, previous_section, args.depth)

0 commit comments

Comments
 (0)