2020import os
2121import subprocess
2222import sys
23+ import time
2324import urllib .error
2425import urllib .request
2526from 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+
97110def 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+
126208def 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:
452598Base: `{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
458604CRITICAL 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