Skip to content

Commit e66d622

Browse files
committed
✨ feat: polish commit flow and AI service
1 parent 755f58b commit e66d622

File tree

8 files changed

+144
-73
lines changed

8 files changed

+144
-73
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: 58 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,52 @@ 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+
for attempt in range(3):
226+
try:
227+
response = self.session.post(
228+
"https://api.openai.com/v1/chat/completions",
229+
headers=headers,
230+
json=data,
231+
timeout=30,
232+
)
233+
if response.status_code >= 500:
234+
raise requests.exceptions.RequestException(
235+
f"Server error: {response.status_code}", response=response
236+
)
237+
break
238+
except requests.exceptions.RequestException as e:
239+
last_exception = e
240+
if attempt == 2:
241+
break
242+
time.sleep(2**attempt)
243+
244+
if last_exception and (not 'response' in locals() or response.status_code >= 500):
245+
if (
246+
hasattr(last_exception, "response")
247+
and last_exception.response is not None
248+
and hasattr(last_exception.response, "text")
249+
):
250+
error_message = last_exception.response.text
251+
else:
252+
error_message = str(last_exception)
253+
raise ValueError(f"API Request failed: {error_message}") from last_exception
223254

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}")
255+
if response.status_code == 400:
256+
error_data = response.json()
257+
error_message = error_data.get("error", {}).get("message", "Unknown error")
258+
raise ValueError(f"API Error: {error_message}")
228259

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

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
265+
try:
266+
commit_data = json.loads(content)
267+
return CommitSuggestion(**commit_data), TokenUsage.from_api_usage(usage)
268+
except json.JSONDecodeError as e:
269+
raise ValueError(f"Failed to parse AI response: {str(e)}") from e
246270

247271
@staticmethod
248272
def format_commit_message(commit_data: CommitSuggestion) -> str:

tests/test_ai_service.py

Lines changed: 53 additions & 17 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,7 +85,7 @@ 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(return_value=MagicMock(status_code=200, json=lambda: mock_response))
8089

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

@@ -85,11 +94,10 @@ def test_generate_commit_message_success(mock_post, ai_service, mock_git_file):
8594
assert usage.total_tokens == 150
8695

8796

88-
@patch("requests.post")
89-
def test_generate_commit_message_api_error(mock_post, ai_service, mock_git_file):
97+
def test_generate_commit_message_api_error(ai_service, mock_git_file):
9098
"""Test handling of API errors."""
91-
mock_post.return_value = MagicMock(
92-
status_code=400, json=lambda: {"error": {"message": "API Error"}}
99+
ai_service.session.post = MagicMock(
100+
return_value=MagicMock(status_code=400, json=lambda: {"error": {"message": "API Error"}})
93101
)
94102

95103
with pytest.raises(ValueError) as exc_info:
@@ -98,33 +106,61 @@ def test_generate_commit_message_api_error(mock_post, ai_service, mock_git_file)
98106
assert "API Error" in str(exc_info.value)
99107

100108

101-
@patch("requests.post")
102-
def test_generate_commit_message_invalid_json(mock_post, ai_service, mock_git_file):
109+
def test_generate_commit_message_invalid_json(ai_service, mock_git_file):
103110
"""Test handling of invalid JSON response."""
104111
mock_response = {
105112
"choices": [{"message": {"content": "Invalid JSON"}}],
106113
"usage": {"prompt_tokens": 100, "completion_tokens": 50, "total_tokens": 150},
107114
}
108115

109-
mock_post.return_value = MagicMock(status_code=200, json=lambda: mock_response)
116+
ai_service.session.post = MagicMock(return_value=MagicMock(status_code=200, json=lambda: mock_response))
110117

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

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

116123

117-
@patch("requests.post")
118-
def test_generate_commit_message_network_error(mock_post, ai_service, mock_git_file):
124+
def test_generate_commit_message_network_error(ai_service, mock_git_file):
119125
"""Test handling of network errors."""
120-
mock_post.side_effect = requests.exceptions.RequestException("Network Error")
126+
ai_service.session.post = MagicMock(side_effect=requests.exceptions.RequestException("Network Error"))
121127

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

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

127133

134+
@patch("time.sleep", return_value=None)
135+
def test_generate_commit_message_retries(mock_sleep, ai_service, mock_git_file):
136+
"""Temporary failures should be retried."""
137+
mock_response = {
138+
"choices": [
139+
{
140+
"message": {
141+
"content": json.dumps(
142+
{
143+
"title": "✨ feat: retry success",
144+
"body": {"Features": {"emoji": "✨", "changes": ["Added new functionality"]}},
145+
"summary": "Added new feature",
146+
}
147+
)
148+
}
149+
}
150+
],
151+
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
152+
}
153+
ai_service.session.post = MagicMock(
154+
side_effect=[
155+
requests.exceptions.RequestException("temp"),
156+
MagicMock(status_code=200, json=lambda: mock_response),
157+
]
158+
)
159+
suggestion, _ = ai_service.generate_commit_message("diff", [mock_git_file("test.py")])
160+
assert suggestion.title == "✨ feat: retry success"
161+
assert ai_service.session.post.call_count == 2
162+
163+
128164
def test_format_commit_message():
129165
"""Test commit message formatting."""
130166
suggestion = CommitSuggestion(

tests/test_analyzer.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,7 @@ def test_analyze_diff_complexity_git_format(analyzer, mock_git_file):
175175

176176
def test_format_cost_for_humans():
177177
"""Test cost formatting."""
178+
assert CommitAnalyzer.format_cost_for_humans(0.0001) == "0.01¢"
178179
assert CommitAnalyzer.format_cost_for_humans(0.001) == "0.10¢"
179180
assert CommitAnalyzer.format_cost_for_humans(0.01) == "1.00¢"
180181
assert CommitAnalyzer.format_cost_for_humans(0.1) == "10.00¢"

tests/test_cli_handler.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -136,9 +136,6 @@ def test_create_batches_with_ignored_files(cli):
136136
GitFile("node_modules/test.js", "A", old_path=None, size=100, hash="def456"),
137137
GitFile("test2.py", "A", old_path=None, size=100, hash="ghi789"),
138138
]
139-
cli.git.get_staged_files = MagicMock(return_value=mock_files)
140-
cli.git.should_ignore_file = MagicMock(side_effect=lambda path: "node_modules" in path)
141-
142139
batches = cli._create_batches(mock_files)
143140

144141
assert len(batches) == 1
@@ -186,10 +183,11 @@ def test_create_combined_commit_success(cli):
186183
},
187184
]
188185
cli.git.create_commit = MagicMock(return_value=True)
189-
190186
cli._create_combined_commit(batches)
191-
192187
cli.git.create_commit.assert_called_once()
188+
args, _ = cli.git.create_commit.call_args
189+
assert args[0] == "📦 chore: combine multiple changes"
190+
assert not args[1].startswith("📦 chore: combine multiple changes")
193191

194192

195193
def test_create_combined_commit_no_changes(cli):

0 commit comments

Comments
 (0)