Compare commits
No commits in common. "24bfef99a20b9c32b2198fe661b54bcc3c1d3fd7" and "e943b03bfb60e51d0679cc63debef3451f9adc95" have entirely different histories.
24bfef99a2
...
e943b03bfb
17 changed files with 1 additions and 2123 deletions
10
.gitignore
vendored
10
.gitignore
vendored
|
@ -1,10 +0,0 @@
|
||||||
# Python-generated files
|
|
||||||
__pycache__/
|
|
||||||
*.py[oc]
|
|
||||||
build/
|
|
||||||
dist/
|
|
||||||
wheels/
|
|
||||||
*.egg-info
|
|
||||||
|
|
||||||
# Virtual environments
|
|
||||||
.venv
|
|
|
@ -1 +0,0 @@
|
||||||
3.13
|
|
|
@ -1,3 +1,3 @@
|
||||||
# ReviewLlama
|
# ReviewLlama
|
||||||
|
|
||||||
Simple coding agent for double checking PRs before you submit them
|
PR Code reviewer to catch syntax and stylistic errors using Ollama
|
|
@ -1,28 +0,0 @@
|
||||||
[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
61
roadmap.md
|
@ -1,61 +0,0 @@
|
||||||
# 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
|
|
|
@ -1,5 +0,0 @@
|
||||||
from reviewllama.cli import cli
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
cli()
|
|
|
@ -1,112 +0,0 @@
|
||||||
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)
|
|
|
@ -1,62 +0,0 @@
|
||||||
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)
|
|
|
@ -1,198 +0,0 @@
|
||||||
"""
|
|
||||||
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),
|
|
||||||
)
|
|
|
@ -1,101 +0,0 @@
|
||||||
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)
|
|
|
@ -1,77 +0,0 @@
|
||||||
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()
|
|
|
@ -1,19 +0,0 @@
|
||||||
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
|
|
|
@ -1,28 +0,0 @@
|
||||||
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.
|
|
|
@ -1,385 +0,0 @@
|
||||||
"""
|
|
||||||
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, "")
|
|
|
@ -1,35 +0,0 @@
|
||||||
"""
|
|
||||||
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()
|
|
Loading…
Add table
Add a link
Reference in a new issue