@@ -389,6 +389,9 @@ def get_previous_reviews(pr_number: int, repo: str | None) -> tuple[list, list]:
389389 return reviews , comments
390390
391391
392+ RPR_MARKER = "<!-- rpr -->"
393+
394+
392395def format_previous_reviews (reviews : list , comments : list ) -> str :
393396 """Format previous reviews into a prompt section. Returns '' if none."""
394397 if not reviews and not comments :
@@ -398,15 +401,25 @@ def format_previous_reviews(reviews: list, comments: list) -> str:
398401
399402 for r in reviews :
400403 body = (r .get ("body" ) or "" ).strip ()
401- if not body :
404+ if not body or RPR_MARKER in body :
402405 continue
403406 user = r .get ("user" , {}).get ("login" , "unknown" )
404407 state = r .get ("state" , "COMMENTED" )
405408 parts .append (f"[{ user } — { state } ]\n { body } " )
406409
410+ # Collect review IDs that came from rpr so we can skip their inline comments
411+ rpr_review_ids = {
412+ r .get ("id" )
413+ for r in reviews
414+ if RPR_MARKER in (r .get ("body" ) or "" )
415+ }
416+
407417 for c in comments :
408418 body = (c .get ("body" ) or "" ).strip ()
409- if not body :
419+ if not body or RPR_MARKER in body :
420+ continue
421+ # Skip inline comments that belong to an rpr review
422+ if c .get ("pull_request_review_id" ) in rpr_review_ids :
410423 continue
411424 user = c .get ("user" , {}).get ("login" , "unknown" )
412425 path = c .get ("path" , "?" )
@@ -432,7 +445,7 @@ def format_previous_reviews(reviews: list, comments: list) -> str:
432445
433446def post_review_comment (pr_number : int , body : str , repo : str | None ):
434447 """Post a simple comment on the PR (not an inline review)."""
435- run_gh (["pr" , "comment" , str (pr_number ), "--body" , body ], repo )
448+ run_gh (["pr" , "comment" , str (pr_number ), "--body" , body + " \n " + RPR_MARKER ], repo )
436449
437450
438451def post_review (
@@ -446,8 +459,8 @@ def post_review(
446459 Submit a pull request review with optional inline comments.
447460 event: COMMENT | APPROVE | REQUEST_CHANGES
448461 """
449- # Build the review payload
450- payload = {"event" : event , "body" : body }
462+ # Build the review payload (marker lets future runs skip our own reviews)
463+ payload = {"event" : event , "body" : body + " \n " + RPR_MARKER }
451464
452465 if comments :
453466 payload ["comments" ] = []
@@ -944,6 +957,315 @@ def parse_review(raw: str) -> dict:
944957 return {"summary" : raw , "comments" : []}
945958
946959
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+
9471269# ---------------------------------------------------------------------------
9481270# Main
9491271# ---------------------------------------------------------------------------
@@ -1031,6 +1353,13 @@ def _bg_update_check():
10311353 nc = len ([c for c in prev_comments if (c .get ("body" ) or "" ).strip ()])
10321354 print (f" Found { nr } prior reviews, { nc } inline comments" , file = sys .stderr )
10331355
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+
10341363 # 5. Build prompt and call Claude
10351364 depth_label = REVIEW_DEPTHS [args .depth ]["label" ]
10361365 system , user = build_prompt (pr_info , diff , valid_lines , previous_section , args .depth )
0 commit comments