Skip to content

Commit bb4079d

Browse files
authored
Merge pull request #11 from dedev-llc/feat/update-notifier
Fix npm bugs, add thread reply processing
2 parents 08dc9a9 + 049033a commit bb4079d

1 file changed

Lines changed: 334 additions & 5 deletions

File tree

src/rpr/cli.py

Lines changed: 334 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
392395
def 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

433446
def 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

438451
def 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

Comments
 (0)