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
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# CHANGES

## [Unreleased]

### Added
- 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).

### 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.

### 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.

7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Automated code review powered by LLM. Works with any OpenAI-compatible API (Open
- **Multiple modes** - CLI, Git hooks, GitHub Actions
- **Flexible** - Works with any OpenAI-compatible endpoint
- **Graceful fallback** - Static analysis when LLM unavailable
- **Offline mode** - Run static analysis only, without LLM calls (`--offline`)

## Quick Start

Expand Down Expand Up @@ -99,6 +100,7 @@ llm-code-review --base main --head dev # Compare branches
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 --test-connection # Test API connectivity
```

Expand Down Expand Up @@ -294,6 +296,7 @@ Add optional inputs to customize behavior:
strict: 'true' # Fail on warnings too (default: false)
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)

- name: Check results
if: always()
Expand All @@ -314,6 +317,7 @@ Add optional inputs to customize behavior:
| `strict` | Fail on warnings | No | `false` |
| `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` |

### Action Outputs

Expand All @@ -336,6 +340,9 @@ llm-code-review --test-connection
# Verbose output
llm-code-review --mode staged --verbose

# Offline static analysis (no API key needed)
llm-code-review --mode staged --offline

# Health check
python monitor.py health
```
Expand Down
102 changes: 102 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ inputs:
description: 'Fail the action if critical issues found (default: true)'
required: false
default: 'true'
inline_comments:
description: 'Post inline review comments on specific code lines (default: true)'
required: false
default: 'true'

outputs:
status:
Expand Down Expand Up @@ -262,6 +266,104 @@ runs:
console.error('Error managing PR comment:', error);
}

- name: Post Inline Review Comments
if: inputs.inline_comments == 'true' && inputs.post_comment == 'true' && github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');

let reviewData = {};
try {
const resultFile = '${{ steps.review.outputs.result_file }}';
const reviewResult = fs.readFileSync(resultFile, 'utf8');
reviewData = JSON.parse(reviewResult);
} catch (error) {
console.log('Could not read review results for inline comments:', error.message);
return;
}

const linePattern = /^(.+?):(\d+):\s*(.+)$/;
const comments = [];

const categories = [
{ key: 'critical_issues', icon: '🚨' },
{ key: 'warnings', icon: '⚠️' },
{ key: 'suggestions', icon: '💡' },
];

for (const { key, icon } of categories) {
const issues = reviewData[key] || [];
for (const issue of issues) {
const match = issue.match(linePattern);
if (match) {
comments.push({
path: match[1],
line: parseInt(match[2], 10),
body: `${icon} ${match[3]}`,
});
}
}
}

if (comments.length === 0) {
console.log('No inline comments to post (no issues with file:line format)');
return;
}

const commitId = '${{ github.event.pull_request.head.sha }}';
const prNumber = context.issue.number;
const owner = context.repo.owner;
const repo = context.repo.repo;

// Post individual inline comments
let posted = 0;
let skipped = 0;

for (const comment of comments) {
try {
await github.rest.pulls.createReviewComment({
owner,
repo,
pull_number: prNumber,
commit_id: commitId,
path: comment.path,
line: comment.line,
body: comment.body,
});
posted++;
} catch (err) {
skipped++;
console.log(`Skipped comment on ${comment.path}:${comment.line} - ${err.message}`);
}
}

console.log(`Inline comments: ${posted} posted, ${skipped} skipped`);

// Create a review to mark the PR as reviewed
try {
const criticalCount = ${{ steps.review.outputs.critical_count }};
const warningCount = ${{ steps.review.outputs.warning_count }};
const event = criticalCount > 0 ? 'REQUEST_CHANGES' : 'COMMENT';
const body = criticalCount > 0
? `LLM Code Review found ${criticalCount} critical issue(s) and ${warningCount} warning(s).`
: warningCount > 0
? `LLM Code Review found ${warningCount} warning(s).`
: 'LLM Code Review passed.';

await github.rest.pulls.createReview({
owner,
repo,
pull_number: prNumber,
commit_id: commitId,
event: event,
body: body,
});
console.log(`Created review with event: ${event}`);
} catch (reviewError) {
console.log('Failed to create review:', reviewError.message);
}

- name: Check for failures
if: inputs.fail_on_critical == 'true'
shell: bash
Expand Down
2 changes: 2 additions & 0 deletions config.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ class ReviewConfig:
"missing_error_handling",
"documentation_gaps",
],
"check_docstrings": True,
"docstring_min_lines": 0,
"file_extensions": [
".py",
".js",
Expand Down
3 changes: 2 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ anyio==4.12.1
python-dotenv==1.0.0
rich==13.7.1
argparse
json5==0.9.14
json5==0.9.14
pytest>=7.0
65 changes: 59 additions & 6 deletions review.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,14 @@ def run(self, args=None):
self.reviewer.trace = self.trace
self.reviewer.trace_llm = self.trace_llm

# Handle mutually exclusive --offline and --test-connection
if parsed_args.offline and parsed_args.test_connection:
print(
"Error: --offline and --test-connection are mutually exclusive.",
file=sys.stderr,
)
return 4

# Handle test-connection before validation
if parsed_args.test_connection:
if not self._validate_config():
Expand All @@ -61,8 +69,14 @@ def run(self, args=None):
print("❌ Connection to LLM failed")
return 1

# Validate configuration for other commands
if not self._validate_config():
# Validate configuration
if parsed_args.offline:
try:
self.parser._run_git(["rev-parse", "--git-dir"])
except RuntimeError:
print("Error: Not in a git repository.", file=sys.stderr)
return 4
elif not self._validate_config():
return 4 # Configuration error

# Validate argument combinations
Expand All @@ -82,11 +96,12 @@ def run(self, args=None):
return 2

try:
# Get diff content
diff_content = self._get_diff_content(parsed_args)

# Perform review
result = self.reviewer.review_diff(diff_content)
if parsed_args.offline:
result = self._run_offline_review(parsed_args)
else:
diff_content = self._get_diff_content(parsed_args)
result = self.reviewer.review_diff(diff_content)

# Output results
self._output_results(result, parsed_args)
Expand All @@ -109,6 +124,7 @@ def _create_parser(self) -> argparse.ArgumentParser:
python review.py --mode unstaged --format json # Review unstaged in JSON
python review.py --mode all --strict # Review all with strict mode
python review.py --base main --head feature # Review between branches
python review.py --mode staged --offline # Offline static analysis only
""",
)

Expand Down Expand Up @@ -154,6 +170,11 @@ def _create_parser(self) -> argparse.ArgumentParser:
action="store_true",
help="Dump full LLM prompts and responses to stderr",
)
parser.add_argument(
"--offline",
action="store_true",
help="Run static analysis only, without LLM (forces docstring checks)",
)

# Utility commands
parser.add_argument(
Expand Down Expand Up @@ -295,6 +316,38 @@ def _format_text_output(self, result: ReviewResult, verbose: bool = False) -> st

return "\n".join(lines)

def _run_offline_review(self, args) -> ReviewResult:
"""Run static analysis only, without LLM. Forces docstring checks."""
from static_analyzer import StaticAnalyzer

# Get raw git diff (StaticAnalyzer expects raw format with +++ headers)
if args.base and args.head:
raw_diff = self.parser.get_diff("range", args.base, args.head)
elif args.mode:
raw_diff = self.parser.get_diff(args.mode)
else:
raw_diff = self.parser.get_diff("staged")

if args.verbose:
parsed_files = self.parser.parse_diff(raw_diff)
if parsed_files:
print(f"Analyzing {len(parsed_files)} file(s):", file=sys.stderr)
for file_data in parsed_files:
print(
f" - {file_data['path']} ({file_data['type']})",
file=sys.stderr,
)
print("", file=sys.stderr)

original_value = self.config.get("review.check_docstrings", True)
self.config.config.setdefault("review", {})["check_docstrings"] = True

analyzer = StaticAnalyzer(self.config)
result = analyzer.analyze_diff(raw_diff)

self.config.config["review"]["check_docstrings"] = original_value
return result

def _get_exit_code(self, result: ReviewResult, args=None) -> int:
"""Get appropriate exit code based on results."""
if result.status == "skipped":
Expand Down
2 changes: 2 additions & 0 deletions review_config.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@
"missing_error_handling",
"documentation_gaps"
],
"check_docstrings": true,
"docstring_min_lines": 0,
"file_extensions": [".py", ".js", ".ts", ".jsx", ".tsx", ".java", ".cpp", ".c", ".h", ".go"],
"exclude_patterns": [
"node_modules/",
Expand Down
2 changes: 2 additions & 0 deletions review_config_example.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
"missing_error_handling",
"documentation_gaps"
],
"check_docstrings": true,
"docstring_min_lines": 0,
"file_extensions": [".py", ".js", ".ts", ".jsx", ".tsx", ".java", ".cpp", ".c", ".h", ".go"],
"exclude_patterns": [
"node_modules/",
Expand Down
Loading