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
60 changes: 37 additions & 23 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,30 +3,44 @@
## [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).
- `--context N` CLI flag to override the number of diff context lines per run (e.g. `--context 20`).
- `context` input in GitHub Action to override context lines per workflow run.
- `output.max_context_lines` config key (default: 10) to control how many surrounding lines are
sent to the LLM alongside each change. Previously hardcoded to 3.
- Git diff now uses `-U{N}` flag matching `output.max_context_lines`, so raw diff and the
LLM-formatted output always use the same context width.
- All diff lines (context before, removed, context after) now carry explicit line numbers in the
LLM-formatted output. Previously only added lines were numbered, causing the LLM to hallucinate
file:line references for context and removed lines.
- `context_after` lines are now included in the LLM-formatted diff. Previously they were parsed
but never sent to the LLM.
- `.yml` and `.yaml` added to the list of reviewed file types, covering GitHub Actions workflows,
docker-compose, Kubernetes manifests, and similar infrastructure files.
- 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.
- Inline PR review comments: GitHub Action 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).

### Changed
- Tightened LLM prompt to reduce noise: requires file:line references, prohibits
generic advice, reports only issues visible in the actual diff.
- `--offline` CLI flag: run static analysis only, without any LLM calls. No API key
or network connection required. Automatically forces `check_docstrings: True`.
Mutually exclusive with `--test-connection`. Useful for quick local checks,
CI environments without API access, or when no internet is available.
- Docstrings suggestions feature: detects functions, methods, and classes missing
documentation comments in added code. Supports Python docstrings, JSDoc (JS/TS/JSX/TSX),
Javadoc, Doxygen (C/C++), godoc (Go), and Rust doc comments. Works in both static analysis fallback
and LLM-powered review modes. Controlled by `review.check_docstrings` config option.
- Default `output.max_context_lines` increased from 3 to 10 to reduce false positives caused by
partially visible try/except blocks and multiline function calls.
- Default `max_tokens_per_request` increased from 4096 to 8192 to handle larger diffs that result
from wider context windows.
- Prompt now explicitly tells the LLM how many context lines are included and instructs it not to
flag constructs (try/except, multiline calls, class definitions) that extend beyond the visible area.
- Tightened LLM prompt to reduce noise: requires file:line references, prohibits generic advice,
reports only issues visible in the actual diff.
- `--offline` CLI flag: run static analysis only, without any LLM calls. No API key or network
connection required. Automatically forces `check_docstrings: True`. Mutually exclusive with
`--test-connection`.
- Docstring suggestions: detects functions, methods, and classes missing documentation comments
in added code. Supports Python docstrings, JSDoc (JS/TS/JSX/TSX), Javadoc, Doxygen (C/C++),
godoc (Go). Works in both LLM and static analysis modes. Controlled by
`review.check_docstrings` config option.

### TODO
- VSCode extension integration: provide real-time code review feedback directly
in the editor via a dedicated VSCode extension.
- PyCharm/IntelliJ IDEA plugin integration: provide code review inspections
and quick-fixes via a dedicated JetBrains IDE plugin.

- VSCode extension integration: provide real-time code review feedback directly in the editor.
- PyCharm/IntelliJ IDEA plugin integration: code review inspections and quick-fixes.
37 changes: 34 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,16 @@ Automated code review powered by LLM. Works with any OpenAI-compatible API (Open
- **Flexible** - Works with any OpenAI-compatible endpoint
- **Graceful fallback** - Static analysis when LLM unavailable
- **Offline mode** - Run static analysis only, without LLM calls (`--offline`)
- **YAML support** - Reviews GitHub Actions, docker-compose, and other YAML files
- **Accurate line numbers** - All diff lines (context, removed, added) carry explicit line numbers,
eliminating hallucinated file:line references in LLM output

## Supported File Types

Python, JavaScript, TypeScript, JSX, TSX, Java, C, C++, Go, YAML (`.yml`, `.yaml`)

> YAML support covers GitHub Actions workflows, docker-compose files, Kubernetes manifests,
> and other infrastructure-as-code files. Configure `review.file_extensions` to add or remove types.

## Quick Start

Expand Down Expand Up @@ -101,6 +111,7 @@ llm-code-review --format json # JSON output for CI/CD
llm-code-review --strict # Block on warnings too
llm-code-review --verbose # Detailed output
llm-code-review --offline # Static analysis only (no API key needed)
llm-code-review --context 15 # Use 15 context lines around each change (default: 10)
llm-code-review --test-connection # Test API connectivity
```

Expand All @@ -114,7 +125,7 @@ Edit `review_config.json` to customize rules, or use environment variables:
| `LLM_BASE_URL` | API endpoint URL | No (uses config) |
| `LLM_MODEL` | Model name | No (uses config) |
| `LLM_TIMEOUT` | Request timeout in seconds (default: 180) | No |
| `LLM_MAX_TOKENS_PER_REQUEST` | Max tokens per review chunk (default: 4096) | No |
| `LLM_MAX_TOKENS_PER_REQUEST` | Max tokens per review chunk (default: 8192) | 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 |

Expand Down Expand Up @@ -169,6 +180,7 @@ For full control over the review prompt, use `custom_prompt` with placeholders:
- `{custom_warnings}` - Formatted list of custom warnings
- `{custom_suggestions}` - Formatted list of custom suggestions
- `{additional_instructions}` - Additional instructions text
- `{context_lines}` - Number of context lines shown around each change (from config)

All placeholders are optional - use only the ones you need. See `custom_prompt_example.txt` for more examples.

Expand All @@ -179,18 +191,36 @@ For large diffs that exceed LLM token limits, the system automatically splits th
```json
{
"llm": {
"max_tokens_per_request": 4096,
"max_tokens_per_request": 8192,
"token_limit_strategy": "chunk",
"chars_per_token": 4
}
}
```

**Options:**
- `max_tokens_per_request` - Maximum tokens per LLM request (default: 4096)
- `max_tokens_per_request` - Maximum tokens per LLM request (default: 8192)
- `token_limit_strategy` - Strategy for large diffs: `"chunk"` (split and review parts) or `"truncate"` (review only the beginning)
- `chars_per_token` - Character to token ratio for estimation (default: 4)

### Context Lines

Control how many surrounding lines of code are shown alongside each change. More context helps the LLM understand multi-line patterns (try/except blocks, long function calls):

```json
{
"output": {
"max_context_lines": 10
}
}
```

Or override per-run with the CLI flag:

```bash
llm-code-review --mode staged --context 20
```

### Example Configs

- `review_config_example.json` - OpenAI configuration
Expand Down Expand Up @@ -321,6 +351,7 @@ Add optional inputs to customize behavior:
| `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` |
| `context` | Number of context lines around each change | No | `10` |

### Action Outputs

Expand Down
12 changes: 12 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ inputs:
description: 'Enable inline code change suggestions (default: true)'
required: false
default: 'true'
context:
description: 'Number of context lines around each change (default: 10)'
required: false

outputs:
status:
Expand Down Expand Up @@ -122,6 +125,15 @@ runs:
ARGS="$ARGS --strict"
fi

if [ -n "${{ inputs.context }}" ]; then
CONTEXT_VAL="${{ inputs.context }}"
if [[ "$CONTEXT_VAL" =~ ^[0-9]+$ ]]; then
ARGS="$ARGS --context $CONTEXT_VAL"
else
echo "Warning: invalid context value '$CONTEXT_VAL', must be a positive integer. Ignoring."
fi
fi

echo "Running: python ${{ github.action_path }}/review.py $ARGS"

set +e
Expand Down
6 changes: 4 additions & 2 deletions config.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ class ReviewConfig:
"api_key_env": "LLM_API_KEY",
"timeout": 180,
"max_retries": 3,
"max_tokens_per_request": 4096,
"max_tokens_per_request": 8192,
"token_limit_strategy": "chunk", # "truncate", "chunk", or "skip"
"chars_per_token": 4, # Character-to-token ratio for estimation
},
Expand Down Expand Up @@ -62,6 +62,8 @@ class ReviewConfig:
".h",
".rs",
".go",
".yml",
".yaml",
],
"exclude_patterns": [
"node_modules/",
Expand All @@ -72,7 +74,7 @@ class ReviewConfig:
"*.spec.js",
],
},
"output": {"format": "text", "show_context": True, "max_context_lines": 3},
"output": {"format": "text", "show_context": True, "max_context_lines": 10},
"fallback": {
"enable_static_analysis": True,
"allow_commit_on_unavailable": True,
Expand Down
61 changes: 39 additions & 22 deletions diff_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,16 @@ def get_diff(
head: Optional[str] = None,
) -> str:
"""Get git diff based on mode."""
context = self.config.get("output.max_context_lines", 10)
u_flag = f"-U{context}"
if mode == "staged":
return self._run_git(["diff", "--cached"])
return self._run_git(["diff", "--cached", u_flag])
elif mode == "unstaged":
return self._run_git(["diff"])
return self._run_git(["diff", u_flag])
elif mode == "all":
return self._run_git(["diff", "HEAD"])
return self._run_git(["diff", "HEAD", u_flag])
elif mode == "range" and base and head:
return self._run_git(["diff", f"{base}...{head}"])
return self._run_git(["diff", f"{base}...{head}", u_flag])
else:
raise ValueError(f"Unsupported diff mode: {mode}")

Expand Down Expand Up @@ -56,6 +58,7 @@ def parse_diff(self, diff_text: str) -> List[Dict[str, Any]]:
files = []
current_file = None
current_hunk = None
old_line_num = 0
new_line_num = 0

lines = diff_text.split("\n")
Expand Down Expand Up @@ -109,6 +112,7 @@ def parse_diff(self, diff_text: str) -> List[Dict[str, Any]]:
new_start = int(match.group(3))
new_count = int(match.group(4) or 1)

old_line_num = old_start
new_line_num = new_start

current_hunk = {
Expand All @@ -125,20 +129,25 @@ def parse_diff(self, diff_text: str) -> List[Dict[str, Any]]:
# Content lines
elif current_hunk:
if line.startswith(" "):
# Context line
# Context line — exists in both old and new file
entry = {"line": new_line_num, "content": line[1:]}
if (
not current_hunk["added_lines"]
and not current_hunk["removed_lines"]
):
current_hunk["context_before"].append(line[1:])
elif current_hunk["added_lines"] or current_hunk["removed_lines"]:
current_hunk["context_after"].append(line[1:])
current_hunk["context_before"].append(entry)
else:
current_hunk["context_after"].append(entry)
old_line_num += 1
new_line_num += 1
elif line.startswith("-"):
# Removed line (do not increment new_line_num)
current_hunk["removed_lines"].append(line[1:])
# Removed line — only in old file
current_hunk["removed_lines"].append(
{"line": old_line_num, "content": line[1:]}
)
old_line_num += 1
elif line.startswith("+"):
# Added line with line number
# Added line — only in new file
current_hunk["added_lines"].append(
{"line": new_line_num, "content": line[1:]}
)
Expand Down Expand Up @@ -191,24 +200,32 @@ def format_for_llm(self, parsed_files: List[Dict[str, Any]]) -> str:
f"Lines {hunk['new_start']}-{hunk['new_start'] + hunk['new_count'] - 1}:"
)

# Show context before if available
max_context = self.config.get("output.max_context_lines", 3)
context_before = hunk["context_before"][-max_context:]
if context_before:
formatted_sections.append("Context before:")
for ctx_line in context_before:
formatted_sections.append(f" {ctx_line}")
max_context = self.config.get("output.max_context_lines", 10)

# Show removed lines
for line in hunk["removed_lines"]:
formatted_sections.append(f"- {line}")
# Context before with line numbers
for entry in hunk["context_before"][-max_context:]:
formatted_sections.append(
f" {entry['line']}: {entry['content']}"
)

# Show added lines with line numbers
# Removed lines with old-file line numbers
for entry in hunk["removed_lines"]:
formatted_sections.append(
f"- {entry['line']}: {entry['content']}"
)

# Added lines with new-file line numbers
for entry in hunk["added_lines"]:
formatted_sections.append(
f"+ {entry['line']}: {entry['content']}"
)

# Context after with line numbers
for entry in hunk["context_after"][:max_context]:
formatted_sections.append(
f" {entry['line']}: {entry['content']}"
)

formatted_sections.append("") # Empty line for readability

return "\n".join(formatted_sections)
Expand Down
11 changes: 11 additions & 0 deletions review.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,14 @@ def _create_parser(self) -> argparse.ArgumentParser:
help="Run static analysis only, without LLM (forces docstring checks)",
)

# Context lines for diff
parser.add_argument(
"--context",
type=int,
metavar="N",
help="Number of context lines around each change (default: 10)",
)

# Utility commands
parser.add_argument(
"--test-connection", action="store_true", help="Test connection to LLM API"
Expand Down Expand Up @@ -216,6 +224,9 @@ def _validate_config(self) -> bool:

def _get_diff_content(self, args) -> str:
"""Get diff content based on arguments."""
if getattr(args, "context", None) is not None:
self.config.config["output"]["max_context_lines"] = args.context

if args.base and args.head:
diff_text = self.parser.get_diff("range", args.base, args.head)
elif args.mode:
Expand Down
6 changes: 3 additions & 3 deletions review_config.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"timeout": 180,
"max_retries": 3,
"fallback_model": "anthropic/claude-haiku",
"max_tokens_per_request": 4096,
"max_tokens_per_request": 8192,
"token_limit_strategy": "chunk",
"chars_per_token": 4
},
Expand Down Expand Up @@ -35,7 +35,7 @@
"enable_code_suggestions": false,
"check_docstrings": true,
"docstring_min_lines": 0,
"file_extensions": [".py", ".js", ".ts", ".jsx", ".tsx", ".java", ".cpp", ".c", ".h", ".go"],
"file_extensions": [".py", ".js", ".ts", ".jsx", ".tsx", ".java", ".cpp", ".c", ".h", ".go", ".yml", ".yaml"],
"exclude_patterns": [
"node_modules/",
".git/",
Expand All @@ -48,7 +48,7 @@
"output": {
"format": "text",
"show_context": true,
"max_context_lines": 3
"max_context_lines": 10
},
"fallback": {
"enable_static_analysis": true,
Expand Down
Loading