Skip to content

Commit df71d9f

Browse files
committed
Restore mainline compatibility after rebase
1 parent 80f1dcf commit df71d9f

File tree

3 files changed

+182
-45
lines changed

3 files changed

+182
-45
lines changed

claudecode/github_action_audit.py

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
# Import existing components we can reuse
1818
from claudecode.findings_filter import FindingsFilter
1919
from claudecode.json_parser import parse_json_with_fallbacks
20+
from claudecode.format_pr_comments import format_pr_comments_for_prompt, is_bot_comment
2021
from claudecode.prompts import get_unified_review_prompt # Backward-compatible import for tests/extensions.
2122
from claudecode.review_orchestrator import ReviewModelConfig, ReviewOrchestrator
2223
from claudecode.constants import (
@@ -27,6 +28,7 @@
2728
SUBPROCESS_TIMEOUT
2829
)
2930
from claudecode.logger import get_logger
31+
from claudecode.review_schema import REVIEW_OUTPUT_SCHEMA
3032

3133
logger = get_logger(__name__)
3234

@@ -201,6 +203,56 @@ def get_pr_diff(self, repo_name: str, pr_number: int) -> str:
201203
response.raise_for_status()
202204

203205
return self._filter_generated_files(response.text)
206+
207+
def get_pr_comments(self, repo_name: str, pr_number: int) -> List[Dict[str, Any]]:
208+
"""Get all review comments for a PR with pagination."""
209+
all_comments = []
210+
page = 1
211+
per_page = 100
212+
213+
while True:
214+
url = f"https://api.github.com/repos/{repo_name}/pulls/{pr_number}/comments"
215+
params = {'per_page': per_page, 'page': page}
216+
217+
try:
218+
response = requests.get(url, headers=self.headers, params=params)
219+
response.raise_for_status()
220+
comments = response.json()
221+
222+
if not comments:
223+
break
224+
225+
all_comments.extend(comments)
226+
if len(comments) < per_page:
227+
break
228+
page += 1
229+
except requests.RequestException as e:
230+
logger.warning(f"Failed to fetch comments page {page}: {e}")
231+
break
232+
233+
return all_comments
234+
235+
def get_comment_reactions(self, repo_name: str, comment_id: int) -> Dict[str, int]:
236+
"""Get reactions for a specific comment, excluding bot reactions."""
237+
url = f"https://api.github.com/repos/{repo_name}/pulls/comments/{comment_id}/reactions"
238+
239+
try:
240+
response = requests.get(url, headers=self.headers)
241+
response.raise_for_status()
242+
reactions = response.json()
243+
244+
counts: Dict[str, int] = {}
245+
for reaction in reactions:
246+
user = reaction.get('user', {})
247+
if user.get('type') == 'Bot':
248+
continue
249+
content = reaction.get('content', '')
250+
if content:
251+
counts[content] = counts.get(content, 0) + 1
252+
return counts
253+
except requests.RequestException as e:
254+
logger.debug(f"Failed to fetch reactions for comment {comment_id}: {e}")
255+
return {}
204256

205257
def _is_excluded(self, filepath: str) -> bool:
206258
"""Check if a file should be excluded based on directory or file patterns."""
@@ -298,7 +350,8 @@ def run_prompt(self, repo_dir: Path, prompt: str, model: Optional[str] = None) -
298350
'claude',
299351
'--output-format', 'json',
300352
'--model', model_name,
301-
'--disallowed-tools', 'Bash(ps:*)'
353+
'--disallowed-tools', 'Bash(ps:*)',
354+
'--json-schema', json.dumps(REVIEW_OUTPUT_SCHEMA),
302355
]
303356

304357
NUM_RETRIES = 3
@@ -370,11 +423,14 @@ def _extract_review_findings(self, claude_output: Any) -> Dict[str, Any]:
370423
# Try to extract JSON from the result text
371424
success, result_json = parse_json_with_fallbacks(result_text, "Claude result text")
372425
if success and result_json and 'findings' in result_json:
426+
if 'pr_summary' not in result_json:
427+
result_json['pr_summary'] = {}
373428
return result_json
374429

375430
# Return empty structure if no findings found
376431
return {
377432
'findings': [],
433+
'pr_summary': {},
378434
'analysis_summary': {
379435
'files_reviewed': 0,
380436
'high_severity': 0,
@@ -676,6 +732,34 @@ def main():
676732
print(json.dumps({'error': f'Failed to fetch PR data: {str(e)}'}))
677733
sys.exit(EXIT_GENERAL_ERROR)
678734

735+
# Backward-compatible context collection for review thread history.
736+
review_context = None
737+
try:
738+
pr_comments = github_client.get_pr_comments(repo_name, pr_number)
739+
if pr_comments:
740+
bot_comment_threads = []
741+
for comment in pr_comments:
742+
if is_bot_comment(comment):
743+
reactions = github_client.get_comment_reactions(repo_name, comment['id'])
744+
replies = [
745+
c for c in pr_comments
746+
if c.get('in_reply_to_id') == comment['id']
747+
]
748+
replies.sort(key=lambda c: c.get('created_at', ''))
749+
bot_comment_threads.append({
750+
'bot_comment': comment,
751+
'replies': replies,
752+
'reactions': reactions,
753+
})
754+
bot_comment_threads.sort(key=lambda t: t['bot_comment'].get('created_at', ''))
755+
if bot_comment_threads:
756+
review_context = format_pr_comments_for_prompt(bot_comment_threads)
757+
if review_context:
758+
logger.info(f"Fetched previous review context ({len(review_context)} chars)")
759+
except Exception as e:
760+
logger.warning(f"Failed to fetch review context (continuing without it): {e}")
761+
review_context = None
762+
679763
max_diff_lines = get_max_diff_lines()
680764
diff_line_count = len(pr_diff.splitlines())
681765
if max_diff_lines and diff_line_count > max_diff_lines:

claudecode/prompts.py

Lines changed: 95 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,31 @@ def _build_hybrid_diff_section(pr_diff: str, max_lines: int) -> str:
4040
"Use this as a starting point only. You MUST validate findings with repository tool reads.\n"
4141
)
4242

43+
def _build_diff_section(pr_diff: Optional[str], include_diff: bool) -> str:
44+
"""Build unified-prompt diff section for backward compatibility."""
45+
if pr_diff and include_diff:
46+
return f"""
47+
48+
PR DIFF CONTENT:
49+
```
50+
{pr_diff}
51+
```
52+
53+
Review the complete diff above. This contains all code changes in the PR.
54+
"""
55+
56+
return """
57+
58+
IMPORTANT - FILE READING INSTRUCTIONS:
59+
You have access to the repository files. For each file listed above, use the Read tool to examine the changes.
60+
Focus on the files that are most likely to contain issues based on the PR context.
61+
62+
To review effectively:
63+
1. Read each modified file to understand the current code
64+
2. Look at surrounding code context when needed to understand the changes
65+
3. Check related files if you need to understand dependencies or usage patterns
66+
"""
67+
4368

4469
def _base_context_block(pr_data: Dict[str, Any], pr_diff: str, max_diff_lines: int) -> str:
4570
"""Shared context block used across prompts."""
@@ -281,48 +306,76 @@ def get_unified_review_prompt(
281306
include_diff: bool = True,
282307
custom_review_instructions: Optional[str] = None,
283308
custom_security_instructions: Optional[str] = None,
309+
review_context: Optional[str] = None,
284310
) -> str:
285-
"""Backward-compatible unified prompt used by tests and direct calls.
286-
287-
The unified prompt now enforces hybrid behavior: even when diff is included,
288-
repository context reads are still mandatory.
289-
"""
290-
diff_text = pr_diff if include_diff else ""
291-
max_lines = len(diff_text.splitlines()) if diff_text else 0
292-
combined = build_quality_prompt(
293-
pr_data,
294-
diff_text,
295-
max_lines,
296-
discovered_context={},
297-
custom_review_instructions=custom_review_instructions,
298-
)
299-
security = build_security_prompt(
300-
pr_data,
301-
diff_text,
302-
max_lines,
303-
discovered_context={},
304-
custom_security_instructions=custom_security_instructions,
305-
)
311+
"""Backward-compatible unified prompt used by tests and direct calls."""
312+
files_changed = _format_files_changed(pr_data)
313+
diff_section = _build_diff_section(pr_diff, include_diff)
314+
custom_review_section = f"\n{custom_review_instructions}\n" if custom_review_instructions else ""
315+
custom_security_section = f"\n{custom_security_instructions}\n" if custom_security_instructions else ""
306316

307-
file_reading_block = (
308-
"\nIMPORTANT - FILE READING INSTRUCTIONS:\n"
309-
"You MUST read changed files and nearby context with repository tools before final findings.\n"
310-
)
311-
if include_diff and diff_text:
312-
diff_anchor = f"\nPR DIFF CONTENT:\n```diff\n{diff_text}\n```\n"
313-
else:
314-
diff_anchor = "\n"
317+
pr_description = (pr_data.get('body', '') or '').strip()
318+
pr_description_section = ""
319+
if pr_description:
320+
if len(pr_description) > 2000:
321+
pr_description = pr_description[:2000] + "... (truncated)"
322+
pr_description_section = f"\nPR Description:\n{pr_description}\n"
315323

316-
return (
317-
f"You are a senior engineer conducting a comprehensive code review of GitHub PR #{pr_data.get('number', 'unknown')}.\n"
318-
"CONTEXT:\n"
319-
f"- Title: {pr_data.get('title', 'unknown')}\n"
320-
"OBJECTIVE:\n"
321-
"Perform a high-signal code quality and security review with mandatory repository context validation.\n"
322-
+ diff_anchor
323-
+ file_reading_block
324-
+ "\nREQUIRED OUTPUT FORMAT:\nJSON only.\n\n"
325-
+ combined
326-
+ "\n\n"
327-
+ security
328-
)
324+
review_context_section = review_context or ""
325+
326+
return f"""
327+
You are a senior engineer conducting a comprehensive code review of GitHub PR #{pr_data.get('number', 'unknown')}: "{pr_data.get('title', 'unknown')}"
328+
329+
CONTEXT:
330+
- Repository: {pr_data.get('head', {}).get('repo', {}).get('full_name', 'unknown')}
331+
- Author: {pr_data.get('user', 'unknown')}
332+
- Files changed: {pr_data.get('changed_files', 0)}
333+
- Lines added: {pr_data.get('additions', 0)}
334+
- Lines deleted: {pr_data.get('deletions', 0)}
335+
{pr_description_section}
336+
Files modified:
337+
{files_changed}{diff_section}{review_context_section}
338+
339+
OBJECTIVE:
340+
Perform a focused, high-signal code review to identify HIGH-CONFIDENCE issues introduced by this PR.
341+
342+
CODE QUALITY CATEGORIES:
343+
- correctness, reliability, performance, maintainability, testing
344+
{custom_review_section}
345+
SECURITY CATEGORIES:
346+
- input validation, authn/authz, crypto/secrets, code execution, data exposure
347+
{custom_security_section}
348+
349+
REQUIRED OUTPUT FORMAT:
350+
351+
{{
352+
"pr_summary": {{
353+
"overview": "2-4 sentence summary of what this PR changes and why it matters",
354+
"file_changes": [
355+
{{
356+
"label": "src/auth.py",
357+
"files": ["src/auth.py"],
358+
"changes": "Brief description of changes (~10 words)"
359+
}}
360+
]
361+
}},
362+
"findings": [
363+
{{
364+
"file": "path/to/file.py",
365+
"line": 42,
366+
"severity": "HIGH|MEDIUM|LOW",
367+
"category": "correctness|reliability|performance|maintainability|testing|security",
368+
"title": "Short summary of the issue",
369+
"description": "What is wrong and where it happens",
370+
"impact": "Concrete impact or failure mode",
371+
"recommendation": "Actionable fix or mitigation",
372+
"confidence": 0.95
373+
}}
374+
]
375+
}}
376+
377+
PR SUMMARY GUIDELINES:
378+
- overview: 2-4 sentences describing WHAT changed and WHY (purpose/goal)
379+
- file_changes: One entry per file or group of related files
380+
- changes: Brief description (~10 words), focus on purpose not implementation
381+
"""

scripts/comment-pr-findings.bun.test.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1076,7 +1076,7 @@ describe('comment-pr-findings.js', () => {
10761076
test('should update existing review in place when state is unchanged and no inline comments', async () => {
10771077
// Existing APPROVED review, new findings also result in APPROVED (no HIGH severity)
10781078
const mockReviews = [
1079-
{ id: 101, state: 'APPROVED', user: { type: 'Bot' }, body: 'No issues found. Changes look good.' }
1079+
{ id: 101, state: 'APPROVED', user: { type: 'Bot' }, body: '<!-- nutrient-code-review-action -->\nNo issues found. Changes look good.' }
10801080
];
10811081

10821082
readFileSyncSpy.mockImplementation((path) => {
@@ -1129,7 +1129,7 @@ describe('comment-pr-findings.js', () => {
11291129
test('should dismiss and create new review when state changes', async () => {
11301130
// Existing APPROVED review, but new findings have HIGH severity = CHANGES_REQUESTED
11311131
const mockReviews = [
1132-
{ id: 101, state: 'APPROVED', user: { type: 'Bot' }, body: 'No issues found. Changes look good.' }
1132+
{ id: 101, state: 'APPROVED', user: { type: 'Bot' }, body: '<!-- nutrient-code-review-action -->\nNo issues found. Changes look good.' }
11331133
];
11341134

11351135
const mockFindings = [{

0 commit comments

Comments
 (0)