From ab9d4cab8580d8d25221730c82f1d8514277fa23 Mon Sep 17 00:00:00 2001 From: Oleksii Radetskyi Date: Thu, 12 Feb 2026 22:30:24 +0100 Subject: [PATCH 1/2] add inline code suggestions --- CHANGELOG.md | 5 + README.md | 3 + action.yml | 20 ++++ config.py | 8 ++ examples/workflow-advanced.yml | 3 + review.py | 23 ++++ review_config.json | 1 + review_config_example.json | 1 + review_core.py | 121 ++++++++++++++++++-- test_code_suggestions.py | 201 +++++++++++++++++++++++++++++++++ 10 files changed, 378 insertions(+), 8 deletions(-) create mode 100644 test_code_suggestions.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d996584..8abdc5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,11 @@ ## [Unreleased] ### Added +- Inline code change suggestions: LLM can now generate concrete code replacements + using GitHub-native `suggestion` blocks. In GitHub Actions, these render as + "Apply suggestion" buttons. Controlled by `code_suggestions` action input + (default: true) and `review.enable_code_suggestions` config / `LLM_CODE_SUGGESTIONS` + env var. CLI displays suggestions in both text and JSON output formats. - Inline PR review comments: GitHub Action now posts review comments directly on specific code lines (file:line format) via Pull Request Review API, in addition to the existing summary comment. Controlled by `inline_comments` input (default: true). diff --git a/README.md b/README.md index 75f8ce0..f11b48d 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,7 @@ Edit `review_config.json` to customize rules, or use environment variables: | `LLM_TIMEOUT` | Request timeout in seconds (default: 180) | No | | `LLM_MAX_TOKENS_PER_REQUEST` | Max tokens per review chunk (default: 4096) | No | | `LLM_TOKEN_LIMIT_STRATEGY` | Strategy when exceeding tokens: `chunk`, `truncate`, or `skip` (default: `chunk`) | No | +| `LLM_CODE_SUGGESTIONS` | Enable inline code change suggestions: `true` or `false` (default: `false`) | No | ### Custom Review Rules @@ -297,6 +298,7 @@ Add optional inputs to customize behavior: post_comment: 'true' # Post review as PR comment (default: true) fail_on_critical: 'true' # Fail action on critical issues (default: true) inline_comments: 'true' # Post inline comments on code lines (default: true) + code_suggestions: 'true' # Enable code change suggestions (default: true) - name: Check results if: always() @@ -318,6 +320,7 @@ Add optional inputs to customize behavior: | `post_comment` | Post PR comment | No | `true` | | `fail_on_critical` | Fail on critical issues | No | `true` | | `inline_comments` | Post inline review comments on code lines | No | `true` | +| `code_suggestions` | Enable inline code change suggestions | No | `true` | ### Action Outputs diff --git a/action.yml b/action.yml index 6e42b62..4f54560 100644 --- a/action.yml +++ b/action.yml @@ -41,6 +41,10 @@ inputs: description: 'Post inline review comments on specific code lines (default: true)' required: false default: 'true' + code_suggestions: + description: 'Enable inline code change suggestions (default: true)' + required: false + default: 'true' outputs: status: @@ -102,6 +106,7 @@ runs: LLM_API_KEY: ${{ inputs.api_key }} LLM_BASE_URL: ${{ inputs.base_url }} LLM_MODEL: ${{ inputs.model }} + LLM_CODE_SUGGESTIONS: ${{ inputs.code_suggestions }} run: | RESULT_FILE="${{ runner.temp }}/llm-review-result.json" @@ -306,6 +311,21 @@ runs: } } + // Code suggestions with GitHub-native suggestion blocks + const codeSuggestions = reviewData.code_suggestions || []; + for (const cs of codeSuggestions) { + const body = `💡 ${cs.description}\n\n\`\`\`suggestion\n${cs.suggested_code}\n\`\`\``; + const comment = { + path: cs.file, + line: cs.line_end, + body: body, + }; + if (cs.line_start !== cs.line_end) { + comment.start_line = cs.line_start; + } + comments.push(comment); + } + if (comments.length === 0) { console.log('No inline comments to post (no issues with file:line format)'); return; diff --git a/config.py b/config.py index 3a65844..9e922f0 100644 --- a/config.py +++ b/config.py @@ -47,6 +47,7 @@ class ReviewConfig: "missing_error_handling", "documentation_gaps", ], + "enable_code_suggestions": False, "check_docstrings": True, "docstring_min_lines": 0, "file_extensions": [ @@ -220,6 +221,13 @@ def get_token_limit_strategy(self) -> str: logger.debug("Using token limit strategy from config: %s", config_value) return config_value + def get_code_suggestions_enabled(self) -> bool: + """Check if code suggestions are enabled. Environment variable takes precedence.""" + env_value = os.getenv("LLM_CODE_SUGGESTIONS") + if env_value is not None: + return env_value.lower() in ("true", "1", "yes") + return bool(self.get("review.enable_code_suggestions", False)) + def get_chars_per_token(self) -> int: """Get character-to-token ratio for estimation.""" config_value = self.get("llm.chars_per_token", 4) diff --git a/examples/workflow-advanced.yml b/examples/workflow-advanced.yml index 8dffe81..624c933 100644 --- a/examples/workflow-advanced.yml +++ b/examples/workflow-advanced.yml @@ -43,6 +43,9 @@ jobs: # Post inline comments on specific code lines (default: true) inline_comments: 'true' + # Enable inline code change suggestions (default: true) + code_suggestions: 'true' + - name: Review Summary if: always() run: | diff --git a/review.py b/review.py index 55520fc..1aa45c7 100755 --- a/review.py +++ b/review.py @@ -253,6 +253,16 @@ def _format_json_output(self, result: ReviewResult) -> Dict[str, Any]: "critical_issues": result.critical_issues, "warnings": result.warnings, "suggestions": result.suggestions, + "code_suggestions": [ + { + "file": cs.file, + "line_start": cs.line_start, + "line_end": cs.line_end, + "description": cs.description, + "suggested_code": cs.suggested_code, + } + for cs in result.code_suggestions + ], "fallback_used": result.fallback_used, "exit_code": self._get_exit_code(result), } @@ -296,6 +306,19 @@ def _format_text_output(self, result: ReviewResult, verbose: bool = False) -> st lines.append(f" • {suggestion}") lines.append("") + # Code suggestions + if result.code_suggestions: + lines.append("🔧 CODE SUGGESTIONS:") + for cs in result.code_suggestions: + if cs.line_end != cs.line_start: + loc = f"{cs.file}:{cs.line_start}-{cs.line_end}" + else: + loc = f"{cs.file}:{cs.line_start}" + lines.append(f" {loc}: {cs.description}") + for code_line in cs.suggested_code.split("\n"): + lines.append(f" {code_line}") + lines.append("") + # Status information if verbose: lines.append("📊 Status Information:") diff --git a/review_config.json b/review_config.json index 044e72f..9f9f9f4 100644 --- a/review_config.json +++ b/review_config.json @@ -32,6 +32,7 @@ "missing_error_handling", "documentation_gaps" ], + "enable_code_suggestions": false, "check_docstrings": true, "docstring_min_lines": 0, "file_extensions": [".py", ".js", ".ts", ".jsx", ".tsx", ".java", ".cpp", ".c", ".h", ".go"], diff --git a/review_config_example.json b/review_config_example.json index 47c5a1f..2569af7 100644 --- a/review_config_example.json +++ b/review_config_example.json @@ -22,6 +22,7 @@ "missing_error_handling", "documentation_gaps" ], + "enable_code_suggestions": false, "check_docstrings": true, "docstring_min_lines": 0, "file_extensions": [".py", ".js", ".ts", ".jsx", ".tsx", ".java", ".cpp", ".c", ".h", ".go"], diff --git a/review_core.py b/review_core.py index 2477c1f..12fb15b 100644 --- a/review_core.py +++ b/review_core.py @@ -3,14 +3,28 @@ Based on existing hello_llm.py structure. """ +import re import time import random import logging from typing import List, Optional, Tuple -from dataclasses import dataclass +from dataclasses import dataclass, field from openai import OpenAI, OpenAIError +CODE_SUGGESTION_RE = re.compile(r"^(.+?):(\d+)(?:-(\d+))?:\s*(.+)$") + + +@dataclass +class CodeSuggestion: + """Concrete code replacement suggestion.""" + + file: str + line_start: int + line_end: int + description: str + suggested_code: str + @dataclass class ReviewResult: @@ -24,6 +38,7 @@ class ReviewResult: raw_response: Optional[str] = None chunks_reviewed: int = 0 total_chunks: int = 0 + code_suggestions: List[CodeSuggestion] = field(default_factory=list) @property def review_outcome(self) -> str: @@ -70,7 +85,7 @@ class LLMReviewer: CRITICAL: file.py:42: issue description WARNING: file.py:15: issue description SUGGESTION: file.py:30: suggestion - +{code_suggestion_format} Changes to review: {diff_content}""" @@ -121,6 +136,28 @@ def _build_prompt(self, diff_content: str) -> str: f"- {rule}" for rule in custom_suggestions if isinstance(rule, str) ) + # Build code suggestion format instructions + if self.config.get_code_suggestions_enabled(): + code_suggestion_format = """ +For suggestions with concrete code fixes, use this format: +SUGGESTION: file.py:42: description +```suggestion +replacement code here +``` + +For multi-line replacements, specify line range: +SUGGESTION: file.py:42-45: description +```suggestion +replacement code +``` + +Rules for code suggestions: +- Only suggest when you have an exact, working replacement +- Keep indentation matching the original code +- Regular SUGGESTION without code block is still fine for general advice""" + else: + code_suggestion_format = "" + # Check for custom prompt - pass all placeholders custom_prompt = prompt_config.get("custom_prompt") if custom_prompt and isinstance(custom_prompt, str): @@ -131,6 +168,7 @@ def _build_prompt(self, diff_content: str) -> str: custom_warnings=warnings_str, custom_suggestions=suggestions_str, additional_instructions=additional, + code_suggestion_format=code_suggestion_format, ) except KeyError as e: self.logger.warning( @@ -144,6 +182,7 @@ def _build_prompt(self, diff_content: str) -> str: custom_warnings=warnings_str, custom_suggestions=suggestions_str, additional_instructions=additional, + code_suggestion_format=code_suggestion_format, ) except KeyError as e: self.logger.error(f"Prompt template error: {e}. Using minimal prompt.") @@ -310,6 +349,7 @@ def _review_chunks( all_critical = [] all_warnings = [] all_suggestions = [] + all_code_suggestions = [] chunks_reviewed = 0 raw_responses = [] @@ -321,6 +361,7 @@ def _review_chunks( all_critical.extend(result.critical_issues) all_warnings.extend(result.warnings) all_suggestions.extend(result.suggestions) + all_code_suggestions.extend(result.code_suggestions) chunks_reviewed += 1 if result.raw_response: raw_responses.append( @@ -346,6 +387,7 @@ def _review_chunks( raw_response="\n\n".join(raw_responses) if raw_responses else None, chunks_reviewed=chunks_reviewed, total_chunks=total_chunks, + code_suggestions=all_code_suggestions, ) def _setup_logging(self): @@ -490,27 +532,89 @@ def _call_llm(self, diff_content: str) -> ReviewResult: return self._parse_llm_response(raw_response) def _parse_llm_response(self, response: str) -> ReviewResult: - """Parse LLM response into structured result.""" + """Parse LLM response into structured result. + + Handles both plain SUGGESTION: lines and SUGGESTION: lines followed + by ```suggestion code blocks (GitHub-native code change suggestions). + """ critical_issues = [] warnings = [] suggestions = [] + code_suggestions = [] lines = response.strip().split("\n") + i = 0 + + while i < len(lines): + line = lines[i].strip() - for line in lines: - line = line.strip() if line.startswith("CRITICAL:"): issue = line[9:].strip() if issue and issue != "NONE": critical_issues.append(issue) + i += 1 + elif line.startswith("WARNING:"): warning = line[8:].strip() if warning and warning != "NONE": warnings.append(warning) + i += 1 + elif line.startswith("SUGGESTION:"): - suggestion = line[11:].strip() - if suggestion and suggestion != "NONE": - suggestions.append(suggestion) + suggestion_text = line[11:].strip() + if not suggestion_text or suggestion_text == "NONE": + i += 1 + continue + + # Peek ahead for ```suggestion block + next_i = i + 1 + if next_i < len(lines) and lines[next_i].strip().startswith( + "```suggestion" + ): + # Try to parse as code suggestion + match = CODE_SUGGESTION_RE.match(suggestion_text) + if match: + file_path = match.group(1) + line_start = int(match.group(2)) + line_end = int(match.group(3)) if match.group(3) else line_start + description = match.group(4) + + # Collect code lines until closing ``` + code_lines = [] + j = next_i + 1 + block_closed = False + while j < len(lines): + if lines[j].strip() == "```": + block_closed = True + break + code_lines.append(lines[j]) + j += 1 + + if block_closed: + code_suggestions.append( + CodeSuggestion( + file=file_path, + line_start=line_start, + line_end=line_end, + description=description, + suggested_code="\n".join(code_lines), + ) + ) + i = j + 1 + else: + # Unclosed block — fallback to plain suggestion + suggestions.append(suggestion_text) + i += 1 + else: + # No file:line match — treat as plain suggestion + suggestions.append(suggestion_text) + i += 1 + else: + # No ```suggestion block — plain suggestion + suggestions.append(suggestion_text) + i += 1 + else: + i += 1 return ReviewResult( status="success", @@ -518,6 +622,7 @@ def _parse_llm_response(self, response: str) -> ReviewResult: warnings=warnings, suggestions=suggestions, raw_response=response, + code_suggestions=code_suggestions, ) def _is_retryable_error(self, error: OpenAIError) -> bool: diff --git a/test_code_suggestions.py b/test_code_suggestions.py new file mode 100644 index 0000000..fa057f9 --- /dev/null +++ b/test_code_suggestions.py @@ -0,0 +1,201 @@ +"""Tests for code suggestion parsing in LLM response parser.""" + +import pytest + +from review_core import CodeSuggestion, LLMReviewer, ReviewResult +from config import ReviewConfig + + +@pytest.fixture +def reviewer(): + """Create a reviewer instance for testing.""" + config = ReviewConfig() + return LLMReviewer(config) + + +class TestCodeSuggestionParsing: + """Tests for _parse_llm_response with code suggestion blocks.""" + + def test_single_code_suggestion(self, reviewer): + """Parse a single SUGGESTION with ```suggestion block.""" + response = ( + "SUGGESTION: app.py:42: Use list comprehension\n" + "```suggestion\n" + "items = [x for x in range(10)]\n" + "```" + ) + result = reviewer._parse_llm_response(response) + + assert len(result.code_suggestions) == 1 + assert len(result.suggestions) == 0 + + cs = result.code_suggestions[0] + assert cs.file == "app.py" + assert cs.line_start == 42 + assert cs.line_end == 42 + assert cs.description == "Use list comprehension" + assert cs.suggested_code == "items = [x for x in range(10)]" + + def test_multiline_range_suggestion(self, reviewer): + """Parse SUGGESTION with line range file.py:10-15.""" + response = ( + "SUGGESTION: utils.py:10-15: Simplify loop\n" + "```suggestion\n" + "for item in items:\n" + " process(item)\n" + "```" + ) + result = reviewer._parse_llm_response(response) + + assert len(result.code_suggestions) == 1 + cs = result.code_suggestions[0] + assert cs.file == "utils.py" + assert cs.line_start == 10 + assert cs.line_end == 15 + assert cs.description == "Simplify loop" + assert cs.suggested_code == "for item in items:\n process(item)" + + def test_mixed_suggestions_and_code_suggestions(self, reviewer): + """Parse response with both plain suggestions and code suggestions.""" + response = ( + "CRITICAL: auth.py:5: Hardcoded password\n" + "WARNING: db.py:20: Missing error handling\n" + "SUGGESTION: config.py:10: Consider using pathlib\n" + "SUGGESTION: app.py:42: Use f-string\n" + "```suggestion\n" + 'msg = f"Hello {name}"\n' + "```\n" + "SUGGESTION: utils.py:8: Add type hint\n" + ) + result = reviewer._parse_llm_response(response) + + assert len(result.critical_issues) == 1 + assert len(result.warnings) == 1 + assert len(result.suggestions) == 2 + assert result.suggestions[0] == "config.py:10: Consider using pathlib" + assert result.suggestions[1] == "utils.py:8: Add type hint" + + assert len(result.code_suggestions) == 1 + cs = result.code_suggestions[0] + assert cs.file == "app.py" + assert cs.line_start == 42 + assert cs.description == "Use f-string" + assert cs.suggested_code == 'msg = f"Hello {name}"' + + def test_unclosed_suggestion_block_fallback(self, reviewer): + """Unclosed ```suggestion block falls back to plain suggestion.""" + response = ( + "SUGGESTION: app.py:42: Use list comprehension\n" + "```suggestion\n" + "items = [x for x in range(10)]\n" + ) + result = reviewer._parse_llm_response(response) + + assert len(result.code_suggestions) == 0 + assert len(result.suggestions) == 1 + assert result.suggestions[0] == "app.py:42: Use list comprehension" + + def test_empty_suggestion_block(self, reviewer): + """Empty ```suggestion block (delete lines).""" + response = "SUGGESTION: app.py:42-45: Remove dead code\n```suggestion\n```" + result = reviewer._parse_llm_response(response) + + assert len(result.code_suggestions) == 1 + cs = result.code_suggestions[0] + assert cs.file == "app.py" + assert cs.line_start == 42 + assert cs.line_end == 45 + assert cs.description == "Remove dead code" + assert cs.suggested_code == "" + + def test_suggestion_without_file_line_format(self, reviewer): + """SUGGESTION without file:line before ```suggestion is plain suggestion.""" + response = ( + "SUGGESTION: Consider refactoring this module\n" + "```suggestion\n" + "some code\n" + "```" + ) + result = reviewer._parse_llm_response(response) + + # No file:line match, so it's a plain suggestion + assert len(result.code_suggestions) == 0 + assert len(result.suggestions) == 1 + assert result.suggestions[0] == "Consider refactoring this module" + + def test_suggestion_none_ignored(self, reviewer): + """SUGGESTION: NONE is ignored.""" + response = "SUGGESTION: NONE\n" + result = reviewer._parse_llm_response(response) + + assert len(result.suggestions) == 0 + assert len(result.code_suggestions) == 0 + + def test_multiple_code_suggestions(self, reviewer): + """Parse multiple code suggestions in one response.""" + response = ( + "SUGGESTION: a.py:1: Fix import\n" + "```suggestion\n" + "import os\n" + "```\n" + "SUGGESTION: b.py:10-12: Simplify\n" + "```suggestion\n" + "return True\n" + "```" + ) + result = reviewer._parse_llm_response(response) + + assert len(result.code_suggestions) == 2 + assert result.code_suggestions[0].file == "a.py" + assert result.code_suggestions[0].line_start == 1 + assert result.code_suggestions[1].file == "b.py" + assert result.code_suggestions[1].line_start == 10 + assert result.code_suggestions[1].line_end == 12 + + def test_code_suggestion_preserves_indentation(self, reviewer): + """Code suggestion preserves indentation in the suggested code.""" + response = ( + "SUGGESTION: app.py:10: Fix indentation\n" + "```suggestion\n" + " if condition:\n" + " do_something()\n" + "```" + ) + result = reviewer._parse_llm_response(response) + + assert len(result.code_suggestions) == 1 + cs = result.code_suggestions[0] + assert cs.suggested_code == " if condition:\n do_something()" + + +class TestReviewResultCodeSuggestions: + """Tests for ReviewResult with code_suggestions field.""" + + def test_default_empty_code_suggestions(self): + """ReviewResult has empty code_suggestions by default.""" + result = ReviewResult( + status="success", + critical_issues=[], + warnings=[], + suggestions=[], + ) + assert result.code_suggestions == [] + + def test_code_suggestions_field(self): + """ReviewResult stores code_suggestions.""" + cs = CodeSuggestion( + file="test.py", + line_start=1, + line_end=1, + description="test", + suggested_code="pass", + ) + result = ReviewResult( + status="success", + critical_issues=[], + warnings=[], + suggestions=[], + code_suggestions=[cs], + ) + assert len(result.code_suggestions) == 1 + assert result.code_suggestions[0].file == "test.py" From 47ed525d43f31951eb990c7743d00332060dee79 Mon Sep 17 00:00:00 2001 From: Oleksii Radetskyi Date: Thu, 12 Feb 2026 22:50:40 +0100 Subject: [PATCH 2/2] stronger prompt for code suggestions --- review_core.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/review_core.py b/review_core.py index 12fb15b..6c28763 100644 --- a/review_core.py +++ b/review_core.py @@ -139,7 +139,8 @@ def _build_prompt(self, diff_content: str) -> str: # Build code suggestion format instructions if self.config.get_code_suggestions_enabled(): code_suggestion_format = """ -For suggestions with concrete code fixes, use this format: +IMPORTANT: When a SUGGESTION references a specific file and line, you MUST include a code block with the exact replacement. Use this format: + SUGGESTION: file.py:42: description ```suggestion replacement code here @@ -152,9 +153,9 @@ def _build_prompt(self, diff_content: str) -> str: ``` Rules for code suggestions: -- Only suggest when you have an exact, working replacement +- Every SUGGESTION with file:line MUST have a ```suggestion block with exact replacement code - Keep indentation matching the original code -- Regular SUGGESTION without code block is still fine for general advice""" +- Only omit the code block for general advice without a specific file:line reference""" else: code_suggestion_format = ""