Skip to content

Commit 2fe9ac3

Browse files
authored
Merge pull request #6 from dedev-llc/feat/streaming-and-review-context
Add streaming progress and previous review awareness
2 parents 1a16e35 + 10d5a8f commit 2fe9ac3

1 file changed

Lines changed: 172 additions & 15 deletions

File tree

src/rpr/cli.py

Lines changed: 172 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import os
2121
import subprocess
2222
import sys
23+
import time
2324
import urllib.error
2425
import urllib.request
2526
from pathlib import Path
@@ -94,6 +95,18 @@ def run_gh(args: list, repo: str | None = None) -> str:
9495
return result.stdout
9596

9697

98+
def try_run_gh(args: list, repo: str | None = None) -> str | None:
99+
"""Like run_gh but returns None on failure instead of exiting."""
100+
cmd = ["gh"]
101+
if repo:
102+
cmd += ["--repo", repo]
103+
cmd += args
104+
result = subprocess.run(cmd, capture_output=True, text=True)
105+
if result.returncode != 0:
106+
return None
107+
return result.stdout
108+
109+
97110
def get_pr_info(pr_number: int, repo: str | None) -> dict:
98111
"""Fetch PR metadata."""
99112
raw = run_gh(
@@ -123,6 +136,75 @@ def get_pr_files(pr_number: int, repo: str | None) -> list:
123136
return json.loads(raw)
124137

125138

139+
def get_previous_reviews(pr_number: int, repo: str | None) -> tuple[list, list]:
140+
"""Fetch previous reviews and inline comments on the PR."""
141+
reviews: list = []
142+
comments: list = []
143+
144+
raw = try_run_gh(
145+
["api", f"repos/{{owner}}/{{repo}}/pulls/{pr_number}/reviews", "--paginate"],
146+
repo,
147+
)
148+
if raw:
149+
try:
150+
reviews = json.loads(raw)
151+
except json.JSONDecodeError:
152+
pass
153+
154+
raw = try_run_gh(
155+
["api", f"repos/{{owner}}/{{repo}}/pulls/{pr_number}/comments", "--paginate"],
156+
repo,
157+
)
158+
if raw:
159+
try:
160+
comments = json.loads(raw)
161+
except json.JSONDecodeError:
162+
pass
163+
164+
return reviews, comments
165+
166+
167+
def format_previous_reviews(reviews: list, comments: list) -> str:
168+
"""Format previous reviews into a prompt section. Returns '' if none."""
169+
if not reviews and not comments:
170+
return ""
171+
172+
parts: list[str] = []
173+
174+
for r in reviews:
175+
body = (r.get("body") or "").strip()
176+
if not body:
177+
continue
178+
user = r.get("user", {}).get("login", "unknown")
179+
state = r.get("state", "COMMENTED")
180+
parts.append(f"[{user}{state}]\n{body}")
181+
182+
for c in comments:
183+
body = (c.get("body") or "").strip()
184+
if not body:
185+
continue
186+
user = c.get("user", {}).get("login", "unknown")
187+
path = c.get("path", "?")
188+
line = c.get("line") or c.get("original_line") or "?"
189+
parts.append(f"[{user} on {path}:{line}]\n{body}")
190+
191+
if not parts:
192+
return ""
193+
194+
section = "PREVIOUS REVIEWS AND COMMENTS ON THIS PR:\n"
195+
section += "---\n"
196+
section += "\n\n".join(parts)
197+
section += "\n---\n\n"
198+
section += (
199+
"IMPORTANT — when considering previous feedback:\n"
200+
"- Do NOT repeat comments that were already made\n"
201+
"- If a previous concern has been addressed in the current diff, do not raise it again\n"
202+
"- If all previous issues are resolved and no new issues found, use \"approve\"\n"
203+
"- Only raise NEW issues not already covered by previous reviews\n\n"
204+
)
205+
return section
206+
207+
126208
def post_review_comment(pr_number: int, body: str, repo: str | None):
127209
"""Post a simple comment on the PR (not an inline review)."""
128210
run_gh(["pr", "comment", str(pr_number), "--body", body], repo)
@@ -356,6 +438,7 @@ def call_claude(prompt: str, system: str, config: dict) -> str:
356438
"max_tokens": config["max_tokens"],
357439
"system": system,
358440
"messages": [{"role": "user", "content": prompt}],
441+
"stream": True,
359442
}
360443

361444
# Enable adaptive thinking for 4.6 / opus models
@@ -370,13 +453,13 @@ def call_claude(prompt: str, system: str, config: dict) -> str:
370453
"content-type": "application/json",
371454
"x-api-key": api_key,
372455
"anthropic-version": "2023-06-01",
456+
"accept": "text/event-stream",
373457
},
374458
method="POST",
375459
)
376460

377461
try:
378-
with urllib.request.urlopen(req, timeout=300) as resp:
379-
response = json.loads(resp.read().decode("utf-8"))
462+
resp = urllib.request.urlopen(req, timeout=300)
380463
except urllib.error.HTTPError as e:
381464
body = e.read().decode("utf-8", errors="replace")
382465
print(f"❌ Claude API error ({e.code}): {body}", file=sys.stderr)
@@ -385,17 +468,80 @@ def call_claude(prompt: str, system: str, config: dict) -> str:
385468
print(f"❌ Network error contacting Claude API: {e.reason}", file=sys.stderr)
386469
sys.exit(1)
387470

388-
if "error" in response:
389-
print(f"❌ Claude API error: {response['error']}", file=sys.stderr)
390-
sys.exit(1)
471+
text_content = ""
472+
start_time = time.time()
473+
474+
try:
475+
while True:
476+
raw_line = resp.readline()
477+
if not raw_line:
478+
break
479+
line = raw_line.decode("utf-8").strip()
480+
481+
if not line.startswith("data: "):
482+
continue
483+
484+
data_str = line[6:]
485+
if data_str == "[DONE]":
486+
break
487+
488+
try:
489+
data = json.loads(data_str)
490+
except json.JSONDecodeError:
491+
continue
492+
493+
event_type = data.get("type")
494+
495+
if event_type == "content_block_start":
496+
block_type = data.get("content_block", {}).get("type")
497+
elapsed = time.time() - start_time
498+
if block_type == "thinking":
499+
print(
500+
f"\r⏳ Thinking... ({elapsed:.0f}s) ",
501+
end="", file=sys.stderr, flush=True,
502+
)
503+
elif block_type == "text":
504+
print(
505+
f"\r✍️ Writing review... ",
506+
end="", file=sys.stderr, flush=True,
507+
)
508+
509+
elif event_type == "content_block_delta":
510+
delta = data.get("delta", {})
511+
elapsed = time.time() - start_time
512+
if delta.get("type") == "thinking_delta":
513+
print(
514+
f"\r⏳ Thinking... ({elapsed:.0f}s) ",
515+
end="", file=sys.stderr, flush=True,
516+
)
517+
elif delta.get("type") == "text_delta":
518+
text_content += delta.get("text", "")
519+
print(
520+
f"\r✍️ Writing review... ({len(text_content)} chars, {elapsed:.0f}s) ",
521+
end="", file=sys.stderr, flush=True,
522+
)
523+
524+
elif event_type == "error":
525+
error_msg = data.get("error", {}).get("message", "Unknown error")
526+
print(f"\n❌ Claude API stream error: {error_msg}", file=sys.stderr)
527+
sys.exit(1)
528+
529+
elif event_type == "message_stop":
530+
break
531+
finally:
532+
resp.close()
533+
534+
elapsed = time.time() - start_time
535+
print(
536+
f"\r✅ Review generated ({elapsed:.1f}s) ",
537+
file=sys.stderr,
538+
)
391539

392-
# Extract text content, skipping thinking blocks
393-
for block in response.get("content", []):
394-
if block.get("type") == "text":
395-
return block["text"]
540+
if not text_content:
541+
print("❌ No text content in Claude's response", file=sys.stderr)
542+
sys.exit(1)
396543

397-
# Fallback
398-
return response["content"][0].get("text", "")
544+
return text_content
399545

400546

401547
# ---------------------------------------------------------------------------
@@ -452,7 +598,7 @@ def get_review_guidelines() -> str:
452598
Base: `{base}` ← Head: `{head}`
453599
{stats}
454600
455-
CHANGED FILES AND LINES (only these are reviewable):
601+
{previous_reviews_section}CHANGED FILES AND LINES (only these are reviewable):
456602
{changed_lines_summary}
457603
458604
CRITICAL CONSTRAINT: You may ONLY comment on lines listed above. These are the \
@@ -496,7 +642,7 @@ def get_review_guidelines() -> str:
496642
{diff}"""
497643

498644

499-
def build_prompt(pr_info: dict, diff: str, valid_lines: dict) -> tuple:
645+
def build_prompt(pr_info: dict, diff: str, valid_lines: dict, previous_reviews: str = "") -> tuple:
500646
"""Build the system and user prompts."""
501647
custom_guidelines = get_review_guidelines()
502648
if custom_guidelines:
@@ -519,6 +665,7 @@ def build_prompt(pr_info: dict, diff: str, valid_lines: dict) -> tuple:
519665
head=pr_info.get("headRefName", "?"),
520666
stats=stats,
521667
changed_lines_summary=changed_lines_summary,
668+
previous_reviews_section=previous_reviews,
522669
diff=diff,
523670
)
524671

@@ -616,8 +763,18 @@ def main():
616763
total_changed = sum(len(v) for v in valid_lines.values())
617764
print(f" Changed lines: {total_changed} across {len(valid_files)} files", file=sys.stderr)
618765

619-
# 4. Build prompt and call Claude
620-
system, user = build_prompt(pr_info, diff, valid_lines)
766+
# 4. Fetch previous reviews for context
767+
print("🔍 Checking previous reviews...", file=sys.stderr)
768+
prev_reviews, prev_comments = get_previous_reviews(args.pr_number, args.repo)
769+
previous_section = format_previous_reviews(prev_reviews, prev_comments)
770+
771+
if previous_section and args.verbose:
772+
nr = len([r for r in prev_reviews if (r.get("body") or "").strip()])
773+
nc = len([c for c in prev_comments if (c.get("body") or "").strip()])
774+
print(f" Found {nr} prior reviews, {nc} inline comments", file=sys.stderr)
775+
776+
# 5. Build prompt and call Claude
777+
system, user = build_prompt(pr_info, diff, valid_lines, previous_section)
621778

622779
print(f"🤖 Reviewing with {config['model']}...", file=sys.stderr)
623780
raw_response = call_claude(user, system, config)

0 commit comments

Comments
 (0)