Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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()
Expand All @@ -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

Expand Down
20 changes: 20 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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"

Expand Down Expand Up @@ -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;
Expand Down
8 changes: 8 additions & 0 deletions config.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ class ReviewConfig:
"missing_error_handling",
"documentation_gaps",
],
"enable_code_suggestions": False,
"check_docstrings": True,
"docstring_min_lines": 0,
"file_extensions": [
Expand Down Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions examples/workflow-advanced.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
23 changes: 23 additions & 0 deletions review.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}
Expand Down Expand Up @@ -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:")
Expand Down
1 change: 1 addition & 0 deletions review_config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
1 change: 1 addition & 0 deletions review_config_example.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
122 changes: 114 additions & 8 deletions review_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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}"""

Expand Down Expand Up @@ -121,6 +136,29 @@ 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 = """
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
```

For multi-line replacements, specify line range:
SUGGESTION: file.py:42-45: description
```suggestion
replacement code
```

Rules for code suggestions:
- Every SUGGESTION with file:line MUST have a ```suggestion block with exact replacement code
- Keep indentation matching the original code
- Only omit the code block for general advice without a specific file:line reference"""
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):
Expand All @@ -131,6 +169,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(
Expand All @@ -144,6 +183,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.")
Expand Down Expand Up @@ -310,6 +350,7 @@ def _review_chunks(
all_critical = []
all_warnings = []
all_suggestions = []
all_code_suggestions = []
chunks_reviewed = 0
raw_responses = []

Expand All @@ -321,6 +362,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(
Expand All @@ -346,6 +388,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):
Expand Down Expand Up @@ -490,34 +533,97 @@ 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",
critical_issues=critical_issues,
warnings=warnings,
suggestions=suggestions,
raw_response=response,
code_suggestions=code_suggestions,
)

def _is_retryable_error(self, error: OpenAIError) -> bool:
Expand Down
Loading