Skip to content

Commit d85d31a

Browse files
authored
Merge pull request #4 from Arakiss/1.5.6
feat: refine commit processing and API reliability
2 parents 755f58b + 5b29770 commit d85d31a

File tree

8 files changed

+236
-82
lines changed

8 files changed

+236
-82
lines changed

commitloom/cli/cli_handler.py

Lines changed: 11 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,6 @@ def _process_single_commit(self, files: list[GitFile]) -> None:
8383
# Print analysis
8484
console.print_warnings(analysis)
8585
self._maybe_create_branch(analysis)
86-
self._maybe_create_branch(analysis)
8786

8887
try:
8988
# Generate commit message
@@ -239,9 +238,7 @@ def _create_batches(self, changed_files: list[GitFile]) -> list[list[GitFile]]:
239238
invalid_files = []
240239

241240
for file in changed_files:
242-
if hasattr(self.git, "should_ignore_file") and self.git.should_ignore_file(
243-
file.path
244-
):
241+
if self.git.should_ignore_file(file.path):
245242
invalid_files.append(file)
246243
console.print_warning(f"Ignoring file: {file.path}")
247244
else:
@@ -289,18 +286,16 @@ def _create_combined_commit(self, batches: list[dict]) -> None:
289286

290287
# Create combined commit message
291288
title = "📦 chore: combine multiple changes"
292-
body = "\n\n".join(
293-
[
294-
title,
295-
"\n".join(
296-
f"{data['emoji']} {category}:" for category, data in all_changes.items()
297-
),
298-
"\n".join(
299-
f"- {change}" for data in all_changes.values() for change in data["changes"]
300-
),
301-
" ".join(summary_points),
302-
]
303-
)
289+
body_parts = [
290+
"\n".join(
291+
f"{data['emoji']} {category}:" for category, data in all_changes.items()
292+
),
293+
"\n".join(
294+
f"- {change}" for data in all_changes.values() for change in data["changes"]
295+
),
296+
" ".join(summary_points),
297+
]
298+
body = "\n\n".join(part for part in body_parts if part)
304299

305300
# Stage and commit all files
306301
self.git.stage_files(all_files)

commitloom/core/analyzer.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,7 @@ def format_cost_for_humans(cost: float) -> str:
175175
elif cost >= 0.01:
176176
return f"{cost*100:.2f}¢"
177177
else:
178-
return "0.10¢" # For very small costs, show as 0.10¢
178+
return f"{cost*100:.2f}¢"
179179

180180
@staticmethod
181181
def get_cost_context(total_cost: float) -> str:

commitloom/core/git.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
import os
55
import subprocess
66
from dataclasses import dataclass
7+
from fnmatch import fnmatch
8+
9+
from ..config.settings import config
710

811
logger = logging.getLogger(__name__)
912

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

44+
@staticmethod
45+
def should_ignore_file(path: str) -> bool:
46+
"""Check if a file should be ignored based on configured patterns."""
47+
for pattern in config.ignored_patterns:
48+
if fnmatch(path, pattern):
49+
return True
50+
return False
51+
4152
@staticmethod
4253
def _handle_git_output(result: subprocess.CompletedProcess, context: str = "") -> None:
4354
"""Handle git command output and log messages."""

commitloom/services/ai_service.py

Lines changed: 59 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import json
44
from dataclasses import dataclass
55
import os
6+
import time
67

78
import requests
89

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

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

110-
# Check if we're dealing with binary files
111-
if diff.startswith("Binary files changed:"):
115+
if has_binary and not text_files:
112116
return (
113117
"Generate a structured commit message for the following binary file changes.\n"
114118
"You must respond ONLY with a valid JSON object.\n\n"
115-
f"Files changed: {files_summary}\n\n"
116-
f"{diff}\n\n"
119+
f"Files changed: {binary_files}\n\n"
117120
"Requirements:\n"
118121
"1. Title: Maximum 50 characters, starting with an appropriate "
119122
"gitemoji (📝 for data files), followed by the semantic commit "
@@ -128,18 +131,22 @@ def generate_prompt(self, diff: str, changed_files: list[GitFile]) -> str:
128131
' "emoji": "📝",\n'
129132
' "changes": [\n'
130133
' "Updated binary files with new data",\n'
131-
' "Files affected: example.bin"\n'
134+
f' "Files affected: {binary_files}"\n'
132135
" ]\n"
133136
" }\n"
134137
" },\n"
135-
' "summary": "Updated binary files with new data"\n'
138+
f' "summary": "Updated binary files: {binary_files}"\n'
136139
"}"
137140
)
138141

139-
return (
142+
prompt = (
140143
"Generate a structured commit message for the following git diff.\n"
141144
"You must respond ONLY with a valid JSON object.\n\n"
142145
f"Files changed: {files_summary}\n\n"
146+
)
147+
if binary_files:
148+
prompt += f"Binary files: {binary_files}\n\n"
149+
prompt += (
143150
"```\n"
144151
f"{diff}\n"
145152
"```\n\n"
@@ -172,6 +179,7 @@ def generate_prompt(self, diff: str, changed_files: list[GitFile]) -> str:
172179
' "summary": "Added new feature X with configuration updates"\n'
173180
"}"
174181
)
182+
return prompt
175183

176184
def generate_commit_message(
177185
self, diff: str, changed_files: list[GitFile]
@@ -213,36 +221,53 @@ def generate_commit_message(
213221
"temperature": 0.7,
214222
}
215223

216-
try:
217-
response = requests.post(
218-
"https://api.openai.com/v1/chat/completions",
219-
headers=headers,
220-
json=data,
221-
timeout=30,
222-
)
224+
last_exception: requests.exceptions.RequestException | None = None
225+
response: requests.Response | None = None
226+
for attempt in range(3):
227+
try:
228+
response = self.session.post(
229+
"https://api.openai.com/v1/chat/completions",
230+
headers=headers,
231+
json=data,
232+
timeout=30,
233+
)
234+
if response.status_code >= 500:
235+
raise requests.exceptions.RequestException(
236+
f"Server error: {response.status_code}", response=response
237+
)
238+
break
239+
except requests.exceptions.RequestException as e:
240+
last_exception = e
241+
if attempt == 2:
242+
break
243+
time.sleep(2**attempt)
244+
245+
if last_exception and (response is None or response.status_code >= 500):
246+
if (
247+
hasattr(last_exception, "response")
248+
and last_exception.response is not None
249+
and hasattr(last_exception.response, "text")
250+
):
251+
error_message = last_exception.response.text
252+
else:
253+
error_message = str(last_exception)
254+
raise ValueError(f"API Request failed: {error_message}") from last_exception
223255

224-
if response.status_code == 400:
225-
error_data = response.json()
226-
error_message = error_data.get("error", {}).get("message", "Unknown error")
227-
raise ValueError(f"API Error: {error_message}")
256+
if response.status_code == 400:
257+
error_data = response.json()
258+
error_message = error_data.get("error", {}).get("message", "Unknown error")
259+
raise ValueError(f"API Error: {error_message}")
228260

229-
response.raise_for_status()
230-
response_data = response.json()
231-
content = response_data["choices"][0]["message"]["content"]
232-
usage = response_data["usage"]
261+
response.raise_for_status()
262+
response_data = response.json()
263+
content = response_data["choices"][0]["message"]["content"]
264+
usage = response_data["usage"]
233265

234-
try:
235-
commit_data = json.loads(content)
236-
return CommitSuggestion(**commit_data), TokenUsage.from_api_usage(usage)
237-
except json.JSONDecodeError as e:
238-
raise ValueError(f"Failed to parse AI response: {str(e)}") from e
239-
240-
except requests.exceptions.RequestException as e:
241-
if hasattr(e, "response") and e.response is not None and hasattr(e.response, "text"):
242-
error_message = e.response.text
243-
else:
244-
error_message = str(e)
245-
raise ValueError(f"API Request failed: {error_message}") from e
266+
try:
267+
commit_data = json.loads(content)
268+
return CommitSuggestion(**commit_data), TokenUsage.from_api_usage(usage)
269+
except json.JSONDecodeError as e:
270+
raise ValueError(f"Failed to parse AI response: {str(e)}") from e
246271

247272
@staticmethod
248273
def format_commit_message(commit_data: CommitSuggestion) -> str:

tests/test_ai_service.py

Lines changed: 85 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -42,17 +42,26 @@ def test_generate_prompt_text_files(ai_service, mock_git_file):
4242

4343
def test_generate_prompt_binary_files(ai_service, mock_git_file):
4444
"""Test prompt generation for binary files."""
45-
files = [mock_git_file("image.png", size=1024)]
46-
diff = "Binary files changed"
45+
files = [mock_git_file("image.png", size=1024, hash_="abc123")]
46+
prompt = ai_service.generate_prompt("", files)
47+
assert "image.png" in prompt
48+
assert "binary file changes" in prompt
4749

48-
prompt = ai_service.generate_prompt(diff, files)
4950

51+
def test_generate_prompt_mixed_files(ai_service, mock_git_file):
52+
"""Prompt should mention both binary and text changes."""
53+
files = [
54+
mock_git_file("image.png", size=1024, hash_="abc123"),
55+
mock_git_file("test.py"),
56+
]
57+
diff = "diff content"
58+
prompt = ai_service.generate_prompt(diff, files)
5059
assert "image.png" in prompt
51-
assert "Binary files changed" in prompt
60+
assert "test.py" in prompt
61+
assert "Binary files" in prompt
5262

5363

54-
@patch("requests.post")
55-
def test_generate_commit_message_success(mock_post, ai_service, mock_git_file):
64+
def test_generate_commit_message_success(ai_service, mock_git_file):
5665
"""Test successful commit message generation."""
5766
mock_response = {
5867
"choices": [
@@ -76,20 +85,25 @@ def test_generate_commit_message_success(mock_post, ai_service, mock_git_file):
7685
"usage": {"prompt_tokens": 100, "completion_tokens": 50, "total_tokens": 150},
7786
}
7887

79-
mock_post.return_value = MagicMock(status_code=200, json=lambda: mock_response)
88+
ai_service.session.post = MagicMock(
89+
return_value=MagicMock(status_code=200, json=lambda: mock_response)
90+
)
8091

81-
suggestion, usage = ai_service.generate_commit_message("test diff", [mock_git_file("test.py")])
92+
suggestion, usage = ai_service.generate_commit_message(
93+
"test diff", [mock_git_file("test.py")]
94+
)
8295

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

87100

88-
@patch("requests.post")
89-
def test_generate_commit_message_api_error(mock_post, ai_service, mock_git_file):
101+
def test_generate_commit_message_api_error(ai_service, mock_git_file):
90102
"""Test handling of API errors."""
91-
mock_post.return_value = MagicMock(
92-
status_code=400, json=lambda: {"error": {"message": "API Error"}}
103+
ai_service.session.post = MagicMock(
104+
return_value=MagicMock(
105+
status_code=400, json=lambda: {"error": {"message": "API Error"}}
106+
)
93107
)
94108

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

100114

101-
@patch("requests.post")
102-
def test_generate_commit_message_invalid_json(mock_post, ai_service, mock_git_file):
115+
def test_generate_commit_message_invalid_json(ai_service, mock_git_file):
103116
"""Test handling of invalid JSON response."""
104117
mock_response = {
105118
"choices": [{"message": {"content": "Invalid JSON"}}],
106119
"usage": {"prompt_tokens": 100, "completion_tokens": 50, "total_tokens": 150},
107120
}
108121

109-
mock_post.return_value = MagicMock(status_code=200, json=lambda: mock_response)
122+
ai_service.session.post = MagicMock(
123+
return_value=MagicMock(status_code=200, json=lambda: mock_response)
124+
)
110125

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

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

116131

117-
@patch("requests.post")
118-
def test_generate_commit_message_network_error(mock_post, ai_service, mock_git_file):
132+
def test_generate_commit_message_network_error(ai_service, mock_git_file):
119133
"""Test handling of network errors."""
120-
mock_post.side_effect = requests.exceptions.RequestException("Network Error")
134+
ai_service.session.post = MagicMock(
135+
side_effect=requests.exceptions.RequestException("Network Error")
136+
)
121137

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

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

127143

144+
@patch("time.sleep", return_value=None)
145+
def test_generate_commit_message_retries(mock_sleep, ai_service, mock_git_file):
146+
"""Temporary failures should be retried."""
147+
mock_response = {
148+
"choices": [
149+
{
150+
"message": {
151+
"content": json.dumps(
152+
{
153+
"title": "✨ feat: retry success",
154+
"body": {
155+
"Features": {
156+
"emoji": "✨",
157+
"changes": ["Added new functionality"],
158+
}
159+
},
160+
"summary": "Added new feature",
161+
}
162+
)
163+
}
164+
}
165+
],
166+
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
167+
}
168+
ai_service.session.post = MagicMock(
169+
side_effect=[
170+
requests.exceptions.RequestException("temp"),
171+
MagicMock(status_code=200, json=lambda: mock_response),
172+
]
173+
)
174+
suggestion, _ = ai_service.generate_commit_message(
175+
"diff", [mock_git_file("test.py")]
176+
)
177+
assert suggestion.title == "✨ feat: retry success"
178+
assert ai_service.session.post.call_count == 2
179+
180+
128181
def test_format_commit_message():
129182
"""Test commit message formatting."""
130183
suggestion = CommitSuggestion(
@@ -146,3 +199,17 @@ def test_ai_service_missing_api_key():
146199
AIService(api_key=None)
147200

148201
assert "API key is required" in str(exc_info.value)
202+
203+
204+
@patch("time.sleep", return_value=None)
205+
def test_generate_commit_message_retries_exhausted(
206+
mock_sleep, ai_service, mock_git_file
207+
):
208+
"""Should raise error after exhausting all retries."""
209+
ai_service.session.post = MagicMock(
210+
side_effect=requests.exceptions.RequestException("temp")
211+
)
212+
with pytest.raises(ValueError) as exc_info:
213+
ai_service.generate_commit_message("diff", [mock_git_file("test.py")])
214+
assert "API Request failed" in str(exc_info.value)
215+
assert ai_service.session.post.call_count == 3

0 commit comments

Comments
 (0)