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: 11 additions & 16 deletions commitloom/cli/cli_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,6 @@ def _process_single_commit(self, files: list[GitFile]) -> None:
# Print analysis
console.print_warnings(analysis)
self._maybe_create_branch(analysis)
self._maybe_create_branch(analysis)

try:
# Generate commit message
Expand Down Expand Up @@ -239,9 +238,7 @@ def _create_batches(self, changed_files: list[GitFile]) -> list[list[GitFile]]:
invalid_files = []

for file in changed_files:
if hasattr(self.git, "should_ignore_file") and self.git.should_ignore_file(
file.path
):
if self.git.should_ignore_file(file.path):
invalid_files.append(file)
console.print_warning(f"Ignoring file: {file.path}")
else:
Expand Down Expand Up @@ -289,18 +286,16 @@ def _create_combined_commit(self, batches: list[dict]) -> None:

# Create combined commit message
title = "📦 chore: combine multiple changes"
body = "\n\n".join(
[
title,
"\n".join(
f"{data['emoji']} {category}:" for category, data in all_changes.items()
),
"\n".join(
f"- {change}" for data in all_changes.values() for change in data["changes"]
),
" ".join(summary_points),
]
)
body_parts = [
"\n".join(
f"{data['emoji']} {category}:" for category, data in all_changes.items()
),
"\n".join(
f"- {change}" for data in all_changes.values() for change in data["changes"]
),
" ".join(summary_points),
]
body = "\n\n".join(part for part in body_parts if part)

# Stage and commit all files
self.git.stage_files(all_files)
Expand Down
2 changes: 1 addition & 1 deletion commitloom/core/analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ def format_cost_for_humans(cost: float) -> str:
elif cost >= 0.01:
return f"{cost*100:.2f}¢"
else:
return "0.10¢" # For very small costs, show as 0.10¢
return f"{cost*100:.2f}¢"

@staticmethod
def get_cost_context(total_cost: float) -> str:
Expand Down
11 changes: 11 additions & 0 deletions commitloom/core/git.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
import os
import subprocess
from dataclasses import dataclass
from fnmatch import fnmatch

from ..config.settings import config

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -38,6 +41,14 @@ def is_renamed(self) -> bool:
class GitOperations:
"""Basic git operations handler."""

@staticmethod
def should_ignore_file(path: str) -> bool:
"""Check if a file should be ignored based on configured patterns."""
for pattern in config.ignored_patterns:
if fnmatch(path, pattern):
return True
return False

@staticmethod
def _handle_git_output(result: subprocess.CompletedProcess, context: str = "") -> None:
"""Handle git command output and log messages."""
Expand Down
93 changes: 59 additions & 34 deletions commitloom/services/ai_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import json
from dataclasses import dataclass
import os
import time

import requests

Expand Down Expand Up @@ -88,6 +89,7 @@ def __init__(self, api_key: str | None = None, test_mode: bool = False):
self.test_mode = test_mode
# Permitir override por variable de entorno
self.model_name = os.getenv("COMMITLOOM_MODEL", config.default_model)
self.session = requests.Session()

@property
def model(self) -> str:
Expand All @@ -106,14 +108,15 @@ def token_usage_from_api_usage(cls, usage: dict[str, int]) -> TokenUsage:
def generate_prompt(self, diff: str, changed_files: list[GitFile]) -> str:
"""Generate the prompt for the AI model."""
files_summary = ", ".join(f.path for f in changed_files)
has_binary = any(f.is_binary for f in changed_files)
binary_files = ", ".join(f.path for f in changed_files if f.is_binary)
text_files = [f for f in changed_files if not f.is_binary]

# Check if we're dealing with binary files
if diff.startswith("Binary files changed:"):
if has_binary and not text_files:
return (
"Generate a structured commit message for the following binary file changes.\n"
"You must respond ONLY with a valid JSON object.\n\n"
f"Files changed: {files_summary}\n\n"
f"{diff}\n\n"
f"Files changed: {binary_files}\n\n"
"Requirements:\n"
"1. Title: Maximum 50 characters, starting with an appropriate "
"gitemoji (📝 for data files), followed by the semantic commit "
Expand All @@ -128,18 +131,22 @@ def generate_prompt(self, diff: str, changed_files: list[GitFile]) -> str:
' "emoji": "📝",\n'
' "changes": [\n'
' "Updated binary files with new data",\n'
' "Files affected: example.bin"\n'
f' "Files affected: {binary_files}"\n'
" ]\n"
" }\n"
" },\n"
' "summary": "Updated binary files with new data"\n'
f' "summary": "Updated binary files: {binary_files}"\n'
"}"
)

return (
prompt = (
"Generate a structured commit message for the following git diff.\n"
"You must respond ONLY with a valid JSON object.\n\n"
f"Files changed: {files_summary}\n\n"
)
if binary_files:
prompt += f"Binary files: {binary_files}\n\n"
prompt += (
"```\n"
f"{diff}\n"
"```\n\n"
Expand Down Expand Up @@ -172,6 +179,7 @@ def generate_prompt(self, diff: str, changed_files: list[GitFile]) -> str:
' "summary": "Added new feature X with configuration updates"\n'
"}"
)
return prompt

def generate_commit_message(
self, diff: str, changed_files: list[GitFile]
Expand Down Expand Up @@ -213,36 +221,53 @@ def generate_commit_message(
"temperature": 0.7,
}

try:
response = requests.post(
"https://api.openai.com/v1/chat/completions",
headers=headers,
json=data,
timeout=30,
)
last_exception: requests.exceptions.RequestException | None = None
response: requests.Response | None = None
for attempt in range(3):
try:
response = self.session.post(
"https://api.openai.com/v1/chat/completions",
headers=headers,
json=data,
timeout=30,
)
if response.status_code >= 500:
raise requests.exceptions.RequestException(
f"Server error: {response.status_code}", response=response
)
break
except requests.exceptions.RequestException as e:
last_exception = e
if attempt == 2:
break
time.sleep(2**attempt)

if last_exception and (response is None or response.status_code >= 500):
if (
hasattr(last_exception, "response")
and last_exception.response is not None
and hasattr(last_exception.response, "text")
):
error_message = last_exception.response.text
else:
error_message = str(last_exception)
raise ValueError(f"API Request failed: {error_message}") from last_exception

if response.status_code == 400:
error_data = response.json()
error_message = error_data.get("error", {}).get("message", "Unknown error")
raise ValueError(f"API Error: {error_message}")
if response.status_code == 400:
error_data = response.json()
error_message = error_data.get("error", {}).get("message", "Unknown error")
raise ValueError(f"API Error: {error_message}")

response.raise_for_status()
response_data = response.json()
content = response_data["choices"][0]["message"]["content"]
usage = response_data["usage"]
response.raise_for_status()
response_data = response.json()
content = response_data["choices"][0]["message"]["content"]
usage = response_data["usage"]

try:
commit_data = json.loads(content)
return CommitSuggestion(**commit_data), TokenUsage.from_api_usage(usage)
except json.JSONDecodeError as e:
raise ValueError(f"Failed to parse AI response: {str(e)}") from e

except requests.exceptions.RequestException as e:
if hasattr(e, "response") and e.response is not None and hasattr(e.response, "text"):
error_message = e.response.text
else:
error_message = str(e)
raise ValueError(f"API Request failed: {error_message}") from e
try:
commit_data = json.loads(content)
return CommitSuggestion(**commit_data), TokenUsage.from_api_usage(usage)
except json.JSONDecodeError as e:
raise ValueError(f"Failed to parse AI response: {str(e)}") from e

@staticmethod
def format_commit_message(commit_data: CommitSuggestion) -> str:
Expand Down
103 changes: 85 additions & 18 deletions tests/test_ai_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,17 +42,26 @@ def test_generate_prompt_text_files(ai_service, mock_git_file):

def test_generate_prompt_binary_files(ai_service, mock_git_file):
"""Test prompt generation for binary files."""
files = [mock_git_file("image.png", size=1024)]
diff = "Binary files changed"
files = [mock_git_file("image.png", size=1024, hash_="abc123")]
prompt = ai_service.generate_prompt("", files)
assert "image.png" in prompt
assert "binary file changes" in prompt

prompt = ai_service.generate_prompt(diff, files)

def test_generate_prompt_mixed_files(ai_service, mock_git_file):
"""Prompt should mention both binary and text changes."""
files = [
mock_git_file("image.png", size=1024, hash_="abc123"),
mock_git_file("test.py"),
]
diff = "diff content"
prompt = ai_service.generate_prompt(diff, files)
assert "image.png" in prompt
assert "Binary files changed" in prompt
assert "test.py" in prompt
assert "Binary files" in prompt


@patch("requests.post")
def test_generate_commit_message_success(mock_post, ai_service, mock_git_file):
def test_generate_commit_message_success(ai_service, mock_git_file):
"""Test successful commit message generation."""
mock_response = {
"choices": [
Expand All @@ -76,20 +85,25 @@ def test_generate_commit_message_success(mock_post, ai_service, mock_git_file):
"usage": {"prompt_tokens": 100, "completion_tokens": 50, "total_tokens": 150},
}

mock_post.return_value = MagicMock(status_code=200, json=lambda: mock_response)
ai_service.session.post = MagicMock(
return_value=MagicMock(status_code=200, json=lambda: mock_response)
)

suggestion, usage = ai_service.generate_commit_message("test diff", [mock_git_file("test.py")])
suggestion, usage = ai_service.generate_commit_message(
"test diff", [mock_git_file("test.py")]
)

assert isinstance(suggestion, CommitSuggestion)
assert suggestion.title == "✨ feat: add new feature"
assert usage.total_tokens == 150


@patch("requests.post")
def test_generate_commit_message_api_error(mock_post, ai_service, mock_git_file):
def test_generate_commit_message_api_error(ai_service, mock_git_file):
"""Test handling of API errors."""
mock_post.return_value = MagicMock(
status_code=400, json=lambda: {"error": {"message": "API Error"}}
ai_service.session.post = MagicMock(
return_value=MagicMock(
status_code=400, json=lambda: {"error": {"message": "API Error"}}
)
)

with pytest.raises(ValueError) as exc_info:
Expand All @@ -98,33 +112,72 @@ def test_generate_commit_message_api_error(mock_post, ai_service, mock_git_file)
assert "API Error" in str(exc_info.value)


@patch("requests.post")
def test_generate_commit_message_invalid_json(mock_post, ai_service, mock_git_file):
def test_generate_commit_message_invalid_json(ai_service, mock_git_file):
"""Test handling of invalid JSON response."""
mock_response = {
"choices": [{"message": {"content": "Invalid JSON"}}],
"usage": {"prompt_tokens": 100, "completion_tokens": 50, "total_tokens": 150},
}

mock_post.return_value = MagicMock(status_code=200, json=lambda: mock_response)
ai_service.session.post = MagicMock(
return_value=MagicMock(status_code=200, json=lambda: mock_response)
)

with pytest.raises(ValueError) as exc_info:
ai_service.generate_commit_message("test diff", [mock_git_file("test.py")])

assert "Failed to parse AI response" in str(exc_info.value)


@patch("requests.post")
def test_generate_commit_message_network_error(mock_post, ai_service, mock_git_file):
def test_generate_commit_message_network_error(ai_service, mock_git_file):
"""Test handling of network errors."""
mock_post.side_effect = requests.exceptions.RequestException("Network Error")
ai_service.session.post = MagicMock(
side_effect=requests.exceptions.RequestException("Network Error")
)

with pytest.raises(ValueError) as exc_info:
ai_service.generate_commit_message("test diff", [mock_git_file("test.py")])

assert "Network Error" in str(exc_info.value)


@patch("time.sleep", return_value=None)
def test_generate_commit_message_retries(mock_sleep, ai_service, mock_git_file):
"""Temporary failures should be retried."""
mock_response = {
"choices": [
{
"message": {
"content": json.dumps(
{
"title": "✨ feat: retry success",
"body": {
"Features": {
"emoji": "✨",
"changes": ["Added new functionality"],
}
},
"summary": "Added new feature",
}
)
}
}
],
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
}
ai_service.session.post = MagicMock(
side_effect=[
requests.exceptions.RequestException("temp"),
MagicMock(status_code=200, json=lambda: mock_response),
]
)
suggestion, _ = ai_service.generate_commit_message(
"diff", [mock_git_file("test.py")]
)
assert suggestion.title == "✨ feat: retry success"
assert ai_service.session.post.call_count == 2


def test_format_commit_message():
"""Test commit message formatting."""
suggestion = CommitSuggestion(
Expand All @@ -146,3 +199,17 @@ def test_ai_service_missing_api_key():
AIService(api_key=None)

assert "API key is required" in str(exc_info.value)


@patch("time.sleep", return_value=None)
def test_generate_commit_message_retries_exhausted(
mock_sleep, ai_service, mock_git_file
):
"""Should raise error after exhausting all retries."""
ai_service.session.post = MagicMock(
side_effect=requests.exceptions.RequestException("temp")
)
with pytest.raises(ValueError) as exc_info:
ai_service.generate_commit_message("diff", [mock_git_file("test.py")])
assert "API Request failed" in str(exc_info.value)
assert ai_service.session.post.call_count == 3
Loading
Loading