Compare commits

..

No commits in common. "e943b03bfb60e51d0679cc63debef3451f9adc95" and "24bfef99a20b9c32b2198fe661b54bcc3c1d3fd7" have entirely different histories.

17 changed files with 2123 additions and 1 deletions

10
.gitignore vendored Normal file
View file

@ -0,0 +1,10 @@
# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info
# Virtual environments
.venv

1
.python-version Normal file
View file

@ -0,0 +1 @@
3.13

View file

@ -1,3 +1,3 @@
# ReviewLlama # ReviewLlama
PR Code reviewer to catch syntax and stylistic errors using Ollama Simple coding agent for double checking PRs before you submit them

28
pyproject.toml Normal file
View file

@ -0,0 +1,28 @@
[project]
name = "reviewllama"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
authors = [{ name = "Alex Selimov", email = "alex@alexselimov.com" }]
requires-python = ">=3.13"
dependencies = [
"gitpython>=3.1.44",
"langchain>=0.3.25",
"langchain-community>=0.3.25",
"langchain-ollama>=0.3.3",
"pytest>=8.4.0",
"rich>=14.0.0",
]
[project.scripts]
reviewllama = "reviewllama:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.pyright]
include = ["src", "reviewllama"]
exclude = ["**/node_modules", "**/__pycache__"]
reportMissingImports = true
typeCheckingMode = "basic"

61
roadmap.md Normal file
View file

@ -0,0 +1,61 @@
# ReviewLlama Technical Roadmap
## Stage 1: Project Setup & CLI Framework
- [x] Initialize project structure with proper package management
- [x] Implement argument parsing for directory, model, and Ollama server parameters
- [x] Create basic CLI interface with help documentation
- [x] Set up logging and error handling framework
## Stage 2: Git Integration
- [ ] Implement git repository detection and validation
- [ ] Build diff extraction functionality between current branch and origin
- [ ] Parse git diff output into structured format (files, hunks, additions/deletions)
- [ ] Handle edge cases (new files, deletions, binary files, merge conflicts)
## Stage 3: Ollama Client Integration
- [ ] Create HTTP client for Ollama API communication
- [ ] Implement model availability checking and validation
- [ ] Build request/response handling with proper error management
- [ ] Add connection testing and retry logic
## Stage 4: Code Context Analysis & RAG Preparation
- [ ] Implement file parsing and syntax tree generation for major languages
- [ ] Build code context extraction (function signatures, class definitions, imports)
- [ ] Create code chunking strategy for large files
- [ ] Develop dependency graph analysis for related code understanding
## Stage 5: RAG Implementation
- [ ] Design vector embedding strategy for code snippets
- [ ] Implement local vector storage (SQLite + embeddings or similar)
- [ ] Build context retrieval system based on code similarity
- [ ] Create context ranking and selection algorithms
## Stage 6: Review Generation Engine
- [ ] Design prompt templates for different review types (security, performance, style, logic)
- [ ] Implement review request formatting with context injection
- [ ] Build response parsing and suggestion extraction
- [ ] Create confidence scoring for suggestions
## Stage 7: Interactive Review Interface
- [ ] Implement terminal UI for displaying suggestions
- [ ] Build yes/no selection system with keyboard navigation
- [ ] Create suggestion categorization and filtering
- [ ] Add batch accept/reject functionality
## Stage 8: Review Application System
- [ ] Implement automatic code modification for accepted suggestions
- [ ] Create backup and rollback mechanisms
- [ ] Build conflict resolution for overlapping changes
- [ ] Add preview mode for showing proposed changes
## Stage 9: Configuration & Persistence
- [ ] Create configuration file system for user preferences
- [ ] Implement review history and suggestion tracking
- [ ] Build ignore patterns and custom rule systems
- [ ] Add project-specific configuration support
## Stage 10: Testing & Polish
- [ ] Comprehensive unit and integration testing
- [ ] Performance optimization for large repositories
- [ ] Error handling refinement and user experience improvements
- [ ] Documentation and installation packaging

View file

@ -0,0 +1,5 @@
from reviewllama.cli import cli
def main() -> None:
cli()

112
src/reviewllama/cli.py Normal file
View file

@ -0,0 +1,112 @@
import argparse
import sys
from pathlib import Path
from typing import List, Optional
from reviewllama.git_diff import analyze_git_repository
from .configs import ReviewConfig, create_config_from_vars
from .logger import (
log_git_analysis_result,
log_git_analysis_start,
log_paths,
log_review_start,
)
def normalize_server_url(url: str) -> str:
"""Normalize Ollama server URL to ensure proper format."""
if not url.startswith(("http://", "https://")):
return f"http://{url}"
return url.rstrip("/")
def create_argument_parser() -> argparse.ArgumentParser:
"""Create and configure the argument parser."""
parser = argparse.ArgumentParser(
prog="reviewllama",
description="AI-powered code review assistant",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
reviewllama . --model gemma3:27b --server localhost:11434
reviewllama src/ tests/ --model llama3.2:7b
""",
)
parser.add_argument(
"paths",
nargs="+",
metavar="PATH",
help="One or more file paths or git directories to review",
)
parser.add_argument(
"--model",
default="llama3.2:3b",
help="Ollama model to use for code review (default: %(default)s)",
)
parser.add_argument(
"--server",
dest="server_url",
default="localhost:11434",
help="Ollama server URL (default: %(default)s)",
)
parser.add_argument(
"--base-branch",
dest="base_branch",
default="master",
help="Base branch to compare against (default: %(default)s)",
)
return parser
def parse_raw_arguments(args: Optional[List[str]] = None) -> argparse.Namespace:
"""Parse command line arguments into raw namespace."""
parser = create_argument_parser()
return parser.parse_args(args)
def transform_namespace_to_config(namespace: argparse.Namespace) -> ReviewConfig:
"""Transform argparse namespace into ReviewConfig."""
paths = [Path(path_str) for path_str in namespace.paths]
return create_config_from_vars(
paths=paths,
model=namespace.model,
server_url=normalize_server_url(namespace.server_url),
# TODO: Update this system prompt. Either allow the user to provide it or engineer our own for this.
system_prompt="You are a helpful AI assistant",
base_branch=namespace.base_branch,
)
def parse_arguments(args: Optional[List[str]] = None) -> ReviewConfig:
"""Parse command line arguments and return validated configuration."""
raw_namespace = parse_raw_arguments(args)
return transform_namespace_to_config(raw_namespace)
def cli() -> None:
"""Main entry point for the CLI."""
try:
config = parse_arguments()
# TODO: Pass config to review engine
log_review_start(config)
log_paths(config.paths)
for path in config.paths:
analysis = analyze_git_repository(path, config.base_branch)
log_git_analysis_start(path, config.base_branch)
log_git_analysis_result(analysis)
print(analysis.diffs)
except SystemExit:
# argparse calls sys.exit on error, let it propagate
raise
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)

View file

@ -0,0 +1,62 @@
from dataclasses import dataclass, field
from pathlib import Path
from typing import List
@dataclass(frozen=True)
class OllamaConfig:
"""Configuration for Ollama client."""
chat_model: str
embedding_model: str
base_url: str
system_prompt: str
# TODO: Update this to be a passed in value
temperature: float = field(default=0.7)
@dataclass(frozen=True)
class ReviewConfig:
"""Complete configuration for ReviewLlama."""
paths: List[Path]
ollama: OllamaConfig
base_branch: str
def create_ollama_config(
model: str,
server_url: str,
system_prompt: str,
temperature=0.7,
embedding_model="nomic-embed-text",
) -> OllamaConfig:
"""Create OllamaConfig with validated parameters."""
return OllamaConfig(
chat_model=model,
embedding_model=embedding_model,
base_url=server_url,
system_prompt=system_prompt,
temperature=temperature,
)
def create_review_config(
paths: List[Path], ollama_config: OllamaConfig, base_branch: str
) -> ReviewConfig:
"""Create complete ReviewConfig from validated components."""
return ReviewConfig(paths=paths, ollama=ollama_config, base_branch=base_branch)
def create_config_from_vars(
paths: List[Path],
model: str,
server_url: str,
system_prompt: str,
base_branch: str,
):
ollama_config = OllamaConfig(
chat_model=model, base_url=server_url, system_prompt=system_prompt
)
return create_review_config(paths, ollama_config, base_branch)

198
src/reviewllama/git_diff.py Normal file
View file

@ -0,0 +1,198 @@
"""
Git analysis module for ReviewLlama using functional programming style.
"""
from dataclasses import dataclass
from pathlib import Path
from typing import List, Optional, Tuple
from git import Repo
from git.diff import Diff
from git.exc import GitCommandError, InvalidGitRepositoryError
from git.objects import Commit
from rich.console import Console
@dataclass(frozen=True)
class GitDiff:
"""Represents a git diff with metadata."""
file_path: str
old_content: str
new_content: str
diff_text: str
change_type: str # 'added', 'modified', 'deleted'
@dataclass(frozen=True)
class GitAnalysis:
"""Complete git analysis result."""
repo: Repo
repository_path: Path
current_branch: str
base_branch: str
diffs: List[GitDiff]
total_files_changed: int
def find_git_repository(path: Path) -> Repo:
"""Find and return git repository from given path."""
try:
return Repo(path, search_parent_directories=True)
except InvalidGitRepositoryError:
raise ValueError(f"No git repository found at or above {path}")
def get_current_branch_name(repo: Repo) -> str:
"""Get the name of the current branch."""
try:
return repo.active_branch.name
except TypeError:
# Detached HEAD state
return repo.head.commit.hexsha[:8]
def branch_exists(repo: Repo, branch_name: str) -> bool:
"""Check if a branch exists in the repository."""
try:
repo.commit(branch_name)
return True
except:
return False
def get_tracked_files(repo: Repo):
return [
entry.abspath
for entry in repo.commit().tree.traverse()
if Path(entry.abspath).is_file()
]
def get_base_branch(repo: Repo, requested_base: str) -> str:
"""Determine the base branch to compare against."""
# Try requested base first
if branch_exists(repo, requested_base):
return requested_base
# Fall back to common master branch names
common_masters = ["master", "main", "develop"]
for branch in common_masters:
if branch_exists(repo, branch):
return branch
raise ValueError(
f"Base branch '{requested_base}' not found and no common master branch exists"
)
def get_diff_between_branches(repo: Repo, base_branch: str, current_branch: str):
"""Get diff between two branches."""
try:
base_commit = repo.commit(base_branch)
current_commit = repo.commit(current_branch)
return base_commit.diff(current_commit)
except GitCommandError as e:
raise ValueError(
f"Failed to get diff between {base_branch} and {current_branch}: {e}"
)
def determine_change_type(diff_item: Diff) -> str:
"""Determine the type of change from a diff item."""
if diff_item.new_file:
return "added"
elif diff_item.deleted_file:
return "deleted"
else:
return "modified"
def extract_file_content(diff_item: Diff, is_old: bool = True) -> str:
"""Extract file content from diff item."""
try:
blob = diff_item.a_blob if is_old else diff_item.b_blob
if blob is None:
return ""
return blob.data_stream.read().decode("utf-8", errors="ignore")
except (UnicodeDecodeError, AttributeError):
return ""
def create_git_diff(diff_item: Diff) -> GitDiff:
"""Create GitDiff from git.Diff object."""
file_path = diff_item.a_path or diff_item.b_path or "unknown"
old_content = extract_file_content(diff_item, is_old=True)
new_content = extract_file_content(diff_item, is_old=False)
diff_text = str(diff_item)
change_type = determine_change_type(diff_item)
return GitDiff(
file_path=file_path,
old_content=old_content,
new_content=new_content,
diff_text=diff_text,
change_type=change_type,
)
def process_diff_items(diff_index) -> List[GitDiff]:
"""Process all diff items into GitDiff objects."""
return [create_git_diff(item) for item in diff_index]
def filter_reviewable_diffs(diffs: List[GitDiff]) -> List[GitDiff]:
"""Filter diffs to only include reviewable files."""
# TODO: Update this to a more complete list of programming language extensions
reviewable_extensions = {
".py",
".js",
".ts",
".java",
".cpp",
".c",
".h",
".hpp",
".go",
".rs",
".rb",
".php",
".cs",
".swift",
".kt",
".scala",
".clj",
".sh",
".sql",
".yml",
".yaml",
".json",
}
def is_reviewable(diff: GitDiff) -> bool:
path = Path(diff.file_path)
return path.suffix.lower() in reviewable_extensions
return [diff for diff in diffs if is_reviewable(diff)]
def analyze_git_repository(path: Path, base_branch: str) -> GitAnalysis:
"""Analyze git repository and extract diffs for review."""
repo = find_git_repository(path)
current_branch = get_current_branch_name(repo)
resolved_base_branch = get_base_branch(repo, base_branch)
diff_index = get_diff_between_branches(repo, resolved_base_branch, current_branch)
all_diffs = process_diff_items(diff_index)
reviewable_diffs = filter_reviewable_diffs(all_diffs)
return GitAnalysis(
repo=repo,
repository_path=path,
current_branch=current_branch,
base_branch=resolved_base_branch,
diffs=reviewable_diffs,
total_files_changed=len(reviewable_diffs),
)

101
src/reviewllama/llm.py Normal file
View file

@ -0,0 +1,101 @@
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from langchain.memory import ConversationBufferMemory
from langchain.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain.schema import BaseMessage
from langchain_core.runnables import RunnableLambda
from langchain_core.runnables.base import RunnableSerializable
from langchain_core.runnables.passthrough import RunnablePassthrough
from langchain_core.vectorstores import VectorStoreRetriever
from langchain_ollama import ChatOllama
from reviewllama.vector_store import get_context_from_store
from .configs import OllamaConfig
@dataclass(frozen=True)
class ChatClient:
chain: RunnableSerializable[dict[str, Any], BaseMessage]
memory: ConversationBufferMemory
def get_last_response_or_none(self):
try:
return self.memory.chat_memory.messages[-1]
except IndexError:
return None
def create_chat_chain(
config: OllamaConfig,
) -> RunnableSerializable[dict[str, Any], BaseMessage]:
"""Create the chat chain for use by the code."""
llm = ChatOllama(
model=config.chat_model,
base_url=config.base_url,
temperature=config.temperature,
)
prompt = ChatPromptTemplate.from_messages(
[
("system", config.system_prompt),
MessagesPlaceholder("chat_history"),
("human", "Context\n{context}\n\nQuestion:\n{input}"),
]
)
def get_chat_history(inputs: dict) -> list:
"""Extract chat history from memory object."""
try:
return inputs["memory"].chat_memory.messages
except AttributeError:
return []
def get_context(inputs: dict) -> str:
"""Extract the RAG context from the input object"""
try:
return inputs["context"]
except AttributeError:
return ""
return (
RunnablePassthrough.assign(
chat_history=RunnableLambda(get_chat_history),
context=RunnableLambda(get_context),
)
| prompt
| llm
)
def create_memory():
return ConversationBufferMemory(memory_key="chat_history", return_messages=True)
def create_chat_client(config: OllamaConfig):
return ChatClient(
chain=create_chat_chain(config),
memory=create_memory(),
)
def chat_with_client(
client: ChatClient, message: str, retriever: VectorStoreRetriever | None = None
) -> ChatClient:
if retriever:
context = get_context_from_store(message, retriever)
else:
context = ""
response = client.chain.invoke(
{"input": message, "memory": client.memory, "context": context}
)
memory = client.memory
memory.chat_memory.add_user_message(message)
memory.chat_memory.add_ai_message(response.content)
return ChatClient(chain=client.chain, memory=memory)

77
src/reviewllama/logger.py Normal file
View file

@ -0,0 +1,77 @@
import sys
from pathlib import Path
from typing import List
from rich.console import Console
from rich.text import Text
from .configs import ReviewConfig
from .git_diff import GitAnalysis
def create_console() -> Console:
"""Create console instance for colored output."""
return Console()
def create_error_console() -> Console:
return Console(stderr=True, style="bold red")
def log_review_start(config: ReviewConfig) -> None:
console = create_console()
"""Log the start of review process with colored output."""
console.print(
f"ReviewLlama - Starting review of {len(config.paths)} path(s)",
style="bold",
)
console.print(f"Model: [cyan]{config.ollama.chat_model}[/cyan]")
console.print(f"Server: [cyan]{config.ollama.base_url}[/cyan]")
console.print()
def log_paths(paths: List[Path]) -> None:
"""Log the paths being reviewed with colored output."""
console = create_console()
console.print("Paths to review:")
for path in paths:
console.print(f"{path}")
console.print()
def log_error(error: str) -> None:
"""Log error message with colored output."""
console = create_error_console()
console.print(f"Error: {error}")
def log_git_analysis_start(path: Path, base_branch: str) -> None:
"""Log the start of git analysis."""
console = create_console()
console.print(f"[dim]Analyzing git repository at:[/dim] {path}")
console.print(f"[dim]Base branch:[/dim] {base_branch}")
def log_git_analysis_result(analysis: GitAnalysis) -> None:
"""Log the results of git analysis."""
console = create_console()
console.print(f"[dim]Current branch:[/dim] {analysis.current_branch}")
console.print(f"[dim]Comparing against:[/dim] {analysis.base_branch}")
console.print(f"[dim]Files changed:[/dim] {analysis.total_files_changed}")
if analysis.diffs:
console.print("\n[bold]Changed files:[/bold]")
for diff in analysis.diffs:
change_color = {
"added": "green",
"modified": "yellow",
"deleted": "red",
}.get(diff.change_type, "white")
console.print(
f" [{change_color}]{diff.change_type.upper():>8}[/{change_color}] {diff.file_path}"
)
else:
console.print("[dim]No reviewable files changed.[/dim]")
console.print()

View file

@ -0,0 +1,19 @@
import socket
import requests
from reviewllama.configs import OllamaConfig
def is_ollama_available(config: OllamaConfig, timeout: int = 5) -> bool:
host, port = config.base_url.split(":")
try:
with socket.create_connection((host, port), timeout=timeout):
pass
# Then check if Ollama API is responding
response = requests.get(f"http://{host}:{port}/api/tags", timeout=timeout)
return response.status_code == 200
except (socket.error, requests.exceptions.RequestException, Exception):
return False

View file

@ -0,0 +1,28 @@
from pathlib import Path
from langchain_community.document_loaders import TextLoader
from langchain_community.vectorstores import FAISS
from langchain_core.documents.base import Document
from langchain_core.vectorstores import VectorStoreRetriever
from langchain_ollama.embeddings import OllamaEmbeddings
def documents_from_list(file_paths: list[Path | str]) -> list[Document]:
return [doc for file_path in file_paths for doc in TextLoader(file_path).load()]
def create_retriever(
file_paths: list[Path | str], embedding_model: str
) -> VectorStoreRetriever:
embeddings = OllamaEmbeddings(model=embedding_model)
vectorstore = FAISS.from_documents(documents_from_list(file_paths), embeddings)
return vectorstore.as_retriever()
def get_context_from_store(message: str, retriever: VectorStoreRetriever):
docs = retriever.get_relevant_documents(message)
return "\n\n".join([doc.page_content for doc in docs])
# TODO: Currently the vector store is reconstructed each time. Add logic for saving/updating the
# vector store instead of reconstructing it each time the code is executed.

0
tests/__init__.py Normal file
View file

385
tests/test_git_diff.py Normal file
View file

@ -0,0 +1,385 @@
"""
Unit tests for git analysis functionality using pytest.
"""
from pathlib import Path
from unittest.mock import Mock, PropertyMock, patch
import pytest
from git.exc import GitCommandError, InvalidGitRepositoryError
from reviewllama.git_diff import (
GitAnalysis,
GitDiff,
analyze_git_repository,
branch_exists,
create_git_diff,
determine_change_type,
extract_file_content,
filter_reviewable_diffs,
find_git_repository,
get_base_branch,
get_current_branch_name,
get_diff_between_branches,
process_diff_items,
)
# Fixtures
@pytest.fixture
def mock_repo():
"""Create a mock git repository."""
repo = Mock()
repo.active_branch.name = "feature-branch"
repo.head.commit.hexsha = "abc123def456"
return repo
@pytest.fixture
def mock_diff_item():
"""Create a mock git diff item."""
diff = Mock()
diff.a_path = "src/main.py"
diff.b_path = "src/main.py"
diff.new_file = False
diff.deleted_file = False
diff.a_blob = Mock()
diff.b_blob = Mock()
diff.a_blob.data_stream.read.return_value = b"old content"
diff.b_blob.data_stream.read.return_value = b"new content"
diff.__str__ = Mock(return_value="diff content")
return diff
@pytest.fixture
def sample_git_diffs():
"""Create sample GitDiff objects for testing."""
return [
GitDiff("src/main.py", "old", "new", "diff", "modified"),
GitDiff("test.js", "old", "new", "diff", "added"),
GitDiff("README.md", "old", "new", "diff", "modified"),
GitDiff("image.png", "old", "new", "diff", "added"),
]
# Tests for find_git_repository
@patch("reviewllama.git_diff.Repo")
def test_find_git_repository_success(mock_repo_class):
"""Test successful git repository detection."""
mock_repo_instance = Mock()
mock_repo_class.return_value = mock_repo_instance
path = Path("/project")
result = find_git_repository(path)
mock_repo_class.assert_called_once_with(path, search_parent_directories=True)
assert result == mock_repo_instance
@patch("reviewllama.git_diff.Repo")
def test_find_git_repository_not_found(mock_repo_class):
"""Test git repository not found."""
mock_repo_class.side_effect = InvalidGitRepositoryError()
path = Path("/not-a-repo")
with pytest.raises(ValueError, match="No git repository found"):
find_git_repository(path)
# Tests for get_current_branch_name
def test_get_current_branch_name_active_branch(mock_repo):
"""Test getting current branch name from active branch."""
result = get_current_branch_name(mock_repo)
assert result == "feature-branch"
def test_get_current_branch_name_detached_head(mock_repo):
"""Test getting current branch name in detached HEAD state."""
type(mock_repo.active_branch).name = PropertyMock(side_effect=TypeError())
result = get_current_branch_name(mock_repo)
assert result == "abc123de" # First 8 chars of hexsha
# Tests for branch_exists
def test_branch_exists_true(mock_repo):
"""Test branch exists returns True when branch found."""
mock_repo.commit.return_value = Mock()
result = branch_exists(mock_repo, "main")
assert result is True
mock_repo.commit.assert_called_once_with("main")
def test_branch_exists_false(mock_repo):
"""Test branch exists returns False when branch not found."""
mock_repo.commit.side_effect = Exception("Branch not found")
result = branch_exists(mock_repo, "nonexistent")
assert result is False
# Tests for get_base_branch
@patch("reviewllama.git_diff.branch_exists")
def test_get_base_branch_requested_exists(mock_branch_exists, mock_repo):
"""Test get_base_branch returns requested branch when it exists."""
mock_branch_exists.return_value = True
result = get_base_branch(mock_repo, "develop")
assert result == "develop"
mock_branch_exists.assert_called_once_with(mock_repo, "develop")
@patch("reviewllama.git_diff.branch_exists")
def test_get_base_branch_fallback_to_master(mock_branch_exists, mock_repo):
"""Test get_base_branch falls back to master when requested not found."""
mock_branch_exists.side_effect = [False, True] # requested=False, master=True
result = get_base_branch(mock_repo, "nonexistent")
assert result == "master"
@patch("reviewllama.git_diff.branch_exists")
def test_get_base_branch_fallback_to_main(mock_branch_exists, mock_repo):
"""Test get_base_branch falls back to main when master not found."""
mock_branch_exists.side_effect = [
False,
False,
True,
] # requested=False, master=False, main=True
result = get_base_branch(mock_repo, "nonexistent")
assert result == "main"
@patch("reviewllama.git_diff.branch_exists")
def test_get_base_branch_no_fallback_found(mock_branch_exists, mock_repo):
"""Test get_base_branch raises error when no fallback found."""
mock_branch_exists.return_value = False
with pytest.raises(ValueError, match="Base branch 'nonexistent' not found"):
get_base_branch(mock_repo, "nonexistent")
# Tests for get_diff_between_branches
def test_get_diff_between_branches_success(mock_repo):
"""Test successful diff between branches."""
base_commit = Mock()
current_commit = Mock()
mock_diff = Mock()
mock_repo.commit.side_effect = [base_commit, current_commit]
base_commit.diff.return_value = mock_diff
result = get_diff_between_branches(mock_repo, "master", "feature")
assert result == mock_diff
base_commit.diff.assert_called_once_with(current_commit)
def test_get_diff_between_branches_git_error(mock_repo):
"""Test diff between branches with git error."""
mock_repo.commit.side_effect = GitCommandError("git", "error")
with pytest.raises(ValueError, match="Failed to get diff"):
get_diff_between_branches(mock_repo, "master", "feature")
# Tests for determine_change_type
def test_determine_change_type_added():
"""Test determine_change_type for added file."""
diff = Mock()
diff.new_file = True
diff.deleted_file = False
result = determine_change_type(diff)
assert result == "added"
def test_determine_change_type_deleted():
"""Test determine_change_type for deleted file."""
diff = Mock()
diff.new_file = False
diff.deleted_file = True
result = determine_change_type(diff)
assert result == "deleted"
def test_determine_change_type_modified():
"""Test determine_change_type for modified file."""
diff = Mock()
diff.new_file = False
diff.deleted_file = False
result = determine_change_type(diff)
assert result == "modified"
# Tests for extract_file_content
def test_extract_file_content_old(mock_diff_item):
"""Test extracting old file content."""
result = extract_file_content(mock_diff_item, is_old=True)
assert result == "old content"
mock_diff_item.a_blob.data_stream.read.assert_called_once()
def test_extract_file_content_new(mock_diff_item):
"""Test extracting new file content."""
result = extract_file_content(mock_diff_item, is_old=False)
assert result == "new content"
mock_diff_item.b_blob.data_stream.read.assert_called_once()
def test_extract_file_content_none_blob():
"""Test extracting content when blob is None."""
diff = Mock()
diff.a_blob = None
result = extract_file_content(diff, is_old=True)
assert result == ""
def test_extract_file_content_decode_error():
"""Test extracting content with decode error."""
diff = Mock()
diff.a_blob.data_stream.read.side_effect = UnicodeDecodeError(
"utf-8", b"", 0, 1, "error"
)
result = extract_file_content(diff, is_old=True)
assert result == ""
# Tests for create_git_diff
def test_create_git_diff(mock_diff_item):
"""Test creating GitDiff from git diff item."""
result = create_git_diff(mock_diff_item)
assert isinstance(result, GitDiff)
assert result.file_path == "src/main.py"
assert result.old_content == "old content"
assert result.new_content == "new content"
assert result.diff_text == "diff content"
assert result.change_type == "modified"
def test_create_git_diff_unknown_path():
"""Test creating GitDiff with unknown file path."""
diff = Mock()
diff.a_path = None
diff.b_path = None
diff.new_file = False
diff.deleted_file = False
diff.a_blob = None
diff.b_blob = None
diff.__str__ = Mock(return_value="diff")
result = create_git_diff(diff)
assert result.file_path == "unknown"
# Tests for process_diff_items
def test_process_diff_items():
"""Test processing multiple diff items."""
diff1 = Mock()
diff1.a_path = "file1.py"
diff1.b_path = "file1.py"
diff1.new_file = False
diff1.deleted_file = False
diff1.a_blob = None
diff1.b_blob = None
diff1.__str__ = Mock(return_value="diff1")
diff2 = Mock()
diff2.a_path = "file2.py"
diff2.b_path = "file2.py"
diff2.new_file = True
diff2.deleted_file = False
diff2.a_blob = None
diff2.b_blob = None
diff2.__str__ = Mock(return_value="diff2")
diff_index = [diff1, diff2]
result = process_diff_items(diff_index)
assert len(result) == 2
assert all(isinstance(diff, GitDiff) for diff in result)
assert result[0].file_path == "file1.py"
assert result[1].file_path == "file2.py"
# Tests for filter_reviewable_diffs
def test_filter_reviewable_diffs(sample_git_diffs):
"""Test filtering reviewable diffs."""
result = filter_reviewable_diffs(sample_git_diffs)
# Should include .py and .js files, exclude .md and .png
assert len(result) == 2
file_paths = [diff.file_path for diff in result]
assert "src/main.py" in file_paths
assert "test.js" in file_paths
assert "README.md" not in file_paths
assert "image.png" not in file_paths
def test_filter_reviewable_diffs_empty():
"""Test filtering reviewable diffs with empty input."""
result = filter_reviewable_diffs([])
assert result == []
# Tests for analyze_git_repository
@patch("reviewllama.git_diff.find_git_repository")
@patch("reviewllama.git_diff.get_current_branch_name")
@patch("reviewllama.git_diff.get_base_branch")
@patch("reviewllama.git_diff.get_diff_between_branches")
@patch("reviewllama.git_diff.process_diff_items")
@patch("reviewllama.git_diff.filter_reviewable_diffs")
def test_analyze_git_repository_success(
mock_filter,
mock_process,
mock_get_diff,
mock_get_base,
mock_get_current,
mock_find_repo,
sample_git_diffs,
):
"""Test successful git repository analysis."""
# Setup mocks
mock_repo = Mock()
mock_find_repo.return_value = mock_repo
mock_get_current.return_value = "feature-branch"
mock_get_base.return_value = "master"
mock_get_diff.return_value = Mock()
mock_process.return_value = sample_git_diffs
mock_filter.return_value = sample_git_diffs[:2] # Filter to 2 items
path = Path("/project")
result = analyze_git_repository(path, "main")
# Verify result
assert isinstance(result, GitAnalysis)
assert result.repository_path == path
assert result.current_branch == "feature-branch"
assert result.base_branch == "master"
assert len(result.diffs) == 2
assert result.total_files_changed == 2
# Verify function calls
mock_find_repo.assert_called_once_with(path)
mock_get_current.assert_called_once_with(mock_repo)
mock_get_base.assert_called_once_with(mock_repo, "main")
mock_get_diff.assert_called_once_with(mock_repo, "master", "feature-branch")
@patch("reviewllama.git_diff.find_git_repository")
def test_analyze_git_repository_not_found(mock_find_repo):
"""Test git repository analysis when repo not found."""
mock_find_repo.side_effect = ValueError("No git repository found")
path = Path("/not-a-repo")
with pytest.raises(ValueError, match="No git repository found"):
analyze_git_repository(path, "")

35
tests/test_llm.py Normal file
View file

@ -0,0 +1,35 @@
"""
Unit tests for llm chat client functionality
"""
import pytest
from reviewllama.configs import create_ollama_config
from reviewllama.llm import chat_with_client, create_chat_client
from reviewllama.utilities import is_ollama_available
@pytest.fixture
def ollama_config():
return create_ollama_config(
"gemma3:4b", "localhost:11434", "You are a helpful assistant.", 0.0
)
@pytest.fixture
def chat_client(ollama_config):
return create_chat_client(ollama_config)
def test_chat_client(ollama_config, chat_client):
if not is_ollama_available(ollama_config):
pytest.skip("Local Ollama server is not available")
chat_client = chat_with_client(
chat_client, "Tell me your name and introduce yourself briefly"
)
response = chat_client.get_last_response_or_none()
assert response is not None
assert len(response.content) > 0
assert "gemma" in response.content.lower()

1000
uv.lock generated Normal file

File diff suppressed because it is too large Load diff