Skip to content

Commit a186c2e

Browse files
committed
refactor(auto_commit): switch to OpenRouter API for branch name and commit message
- Replace local prompt flow with send_to_openrouter for branch name suggestion - Remove tempfile usage and find_prompt_command; rely on API inputs - Introduce API config: timeout_seconds, api_key_env, api_base; pass through to API calls - Validate and sanitize API responses; handle empty or invalid results gracefully - Update generate_commit_message to use OpenRouter API; remove prompt_cmd dependency - Adjust protected-branch handling to rely on API-based suggestions - Enhance prompt.py parsing to support content field and reasoning fallback from API responses - Improve error handling paths when API calls fail or return unexpected data - Update integration tests to simulate API failures and mock send_to_openrouter - Remove tests related to finding ab-prompt; adapt tests to API-based flow - Minor import cleanup by removing tempfile usage and related code paths
1 parent 1709e3f commit a186c2e

4 files changed

Lines changed: 101 additions & 131 deletions

File tree

src/ab_cli/commands/auto_commit.py

Lines changed: 60 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@
88
import os
99
import subprocess
1010
import sys
11-
import tempfile
1211

1312
from ab_cli.core.config import get_config, estimate_tokens, get_language
13+
from ab_cli.commands.prompt import send_to_openrouter
1414

1515
# ANSI colors
1616
RED = '\033[0;31m'
@@ -84,8 +84,9 @@ def create_branch(branch_name: str) -> bool:
8484
return False
8585

8686

87-
def suggest_branch_name(diff: str, name_status: str, prompt_cmd: str, lang: str) -> str:
87+
def suggest_branch_name(diff: str, name_status: str, lang: str) -> str:
8888
"""Generate a suggested branch name based on changes."""
89+
import re
8990
config = get_config()
9091

9192
prompt_text = f"""Analyze these git changes and suggest a branch name.
@@ -113,32 +114,49 @@ def suggest_branch_name(diff: str, name_status: str, prompt_cmd: str, lang: str)
113114

114115
estimated_tokens = estimate_tokens(prompt_text)
115116
selected_model = config.select_model(estimated_tokens)
116-
117-
with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f:
118-
f.write(prompt_text)
119-
prompt_file = f.name
117+
timeout_s = config.get_with_default('global.timeout_seconds')
118+
api_key_env = config.get_with_default('global.api_key_env')
119+
api_base = config.get_with_default('global.api_base')
120120

121121
try:
122-
result = subprocess.run(
123-
[prompt_cmd, '--model', selected_model, '--lang', lang,
124-
'--max-completion-tokens', '100', '--only-output', '--prompt', '-'],
125-
stdin=open(prompt_file, 'r'),
126-
capture_output=True,
127-
text=True,
128-
check=False
122+
result = send_to_openrouter(
123+
prompt=prompt_text,
124+
context="",
125+
lang=lang,
126+
specialist=None,
127+
model_name=selected_model,
128+
timeout_s=timeout_s,
129+
max_completion_tokens=-1, # No limit
130+
api_key_env=api_key_env,
131+
api_base=api_base
129132
)
130-
branch_name = result.stdout.strip()
133+
134+
if not result:
135+
log_error("API call failed for branch suggestion")
136+
return ""
137+
138+
branch_name = result.get('text', '').strip()
139+
140+
if not branch_name:
141+
log_error("LLM returned empty response for branch name")
142+
return ""
143+
131144
# Clean up
132-
import re
133145
branch_name = branch_name.strip('"\'`')
134146
branch_name = branch_name.split('\n')[0].strip()
135147
branch_name = re.sub(r'\s+', '-', branch_name)
136148
branch_name = re.sub(r'[^a-zA-Z0-9/_-]', '', branch_name)
137149
if len(branch_name) > 50:
138150
branch_name = branch_name[:50].rstrip('-')
151+
152+
if not branch_name:
153+
log_error("Branch name became empty after cleanup")
154+
return ""
155+
139156
return branch_name
140-
finally:
141-
os.unlink(prompt_file)
157+
except Exception as e:
158+
log_error(f"Exception suggesting branch name: {e}")
159+
return ""
142160

143161

144162
def get_staged_files() -> str:
@@ -196,26 +214,9 @@ def get_latest_commit() -> str:
196214
return result.stdout.strip()
197215

198216

199-
def find_prompt_command() -> str:
200-
"""Find the ab-prompt command."""
201-
# Try to find it in the bin directory relative to this module
202-
import pathlib
203-
module_dir = pathlib.Path(__file__).parent.parent.parent.parent
204-
prompt_cmd = module_dir / 'bin' / 'ab-prompt'
205-
if prompt_cmd.exists():
206-
return str(prompt_cmd)
207-
208-
# Fallback to PATH
209-
import shutil
210-
if shutil.which('ab-prompt'):
211-
return 'ab-prompt'
212-
213-
raise FileNotFoundError("Could not find ab-prompt command")
214-
215-
216217
def generate_commit_message(diff: str, name_status: str, recent_commits: str,
217-
lang: str, prompt_cmd: str) -> str:
218-
"""Generate commit message using the prompt utility."""
218+
lang: str) -> str:
219+
"""Generate commit message using the LLM."""
219220
config = get_config()
220221

221222
# Build the prompt
@@ -245,38 +246,40 @@ def generate_commit_message(diff: str, name_status: str, recent_commits: str,
245246
# Estimate tokens and select model
246247
estimated_tokens = estimate_tokens(prompt_text)
247248
selected_model = config.select_model(estimated_tokens)
249+
timeout_s = config.get_with_default('global.timeout_seconds')
250+
api_key_env = config.get_with_default('global.api_key_env')
251+
api_base = config.get_with_default('global.api_base')
248252

249253
log_info(f"Estimated tokens: ~{estimated_tokens} | Model: {selected_model} | Lang: {lang}")
250254
print()
251255

252-
# Write prompt to temp file and use stdin
253-
with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f:
254-
f.write(prompt_text)
255-
prompt_file = f.name
256+
result = send_to_openrouter(
257+
prompt=prompt_text,
258+
context="",
259+
lang=lang,
260+
specialist=None,
261+
model_name=selected_model,
262+
timeout_s=timeout_s,
263+
max_completion_tokens=-1,
264+
api_key_env=api_key_env,
265+
api_base=api_base
266+
)
256267

257-
try:
258-
result = subprocess.run(
259-
[prompt_cmd, '--model', selected_model, '--lang', lang,
260-
'--max-completion-tokens', '-1', '--only-output', '--prompt', '-'],
261-
stdin=open(prompt_file, 'r'),
262-
capture_output=True,
263-
text=True,
264-
check=True
265-
)
266-
return result.stdout.strip()
267-
finally:
268-
os.unlink(prompt_file)
268+
if not result:
269+
raise RuntimeError("API call failed for commit message generation")
270+
271+
return result.get('text', '').strip()
269272

270273

271274
def handle_protected_branch(current_branch: str, diff: str, name_status: str,
272-
prompt_cmd: str, lang: str) -> bool:
275+
lang: str) -> bool:
273276
"""Handle protected branch workflow. Returns True if should continue, False to abort."""
274277
log_warning(f"You are on '{current_branch}' branch.")
275278
print()
276279

277280
# Try to suggest a branch name
278281
log_info("Suggesting branch name...")
279-
suggested_branch = suggest_branch_name(diff, name_status, prompt_cmd, lang)
282+
suggested_branch = suggest_branch_name(diff, name_status, lang)
280283

281284
if suggested_branch:
282285
print(f"\n{GREEN}Suggested branch:{NC} {YELLOW}{suggested_branch}{NC}\n")
@@ -359,13 +362,6 @@ def main():
359362
log_error("Not inside a git repository")
360363
sys.exit(1)
361364

362-
# Find prompt command
363-
try:
364-
prompt_cmd = find_prompt_command()
365-
except FileNotFoundError as e:
366-
log_error(str(e))
367-
sys.exit(1)
368-
369365
# Change to repo root
370366
os.chdir(get_repo_root())
371367

@@ -437,19 +433,17 @@ def main():
437433

438434
# Handle protected branch (after staging so we have the diff for suggestions)
439435
if on_protected_branch:
440-
handle_protected_branch(current_branch, diff, name_status, prompt_cmd, args.lang)
436+
handle_protected_branch(current_branch, diff, name_status, args.lang)
441437

442438
# Generate commit message
443439
log_info("Generating commit message...")
444440

445441
try:
446442
commit_msg = generate_commit_message(
447-
diff, name_status, recent_commits, args.lang, prompt_cmd
443+
diff, name_status, recent_commits, args.lang
448444
)
449-
except subprocess.CalledProcessError as e:
445+
except Exception as e:
450446
log_error(f"Failed to generate commit message: {e}")
451-
if e.stderr:
452-
print(e.stderr, file=sys.stderr)
453447
sys.exit(1)
454448

455449
if not commit_msg:

src/ab_cli/commands/prompt.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,18 @@ def send_to_openrouter(prompt: str, context: str, lang: str, specialist: Optiona
142142
response.raise_for_status()
143143
data = response.json()
144144

145-
text_response = data['choices'][0]['message']['content']
145+
message = data['choices'][0]['message']
146+
text_response = message.get('content') or ''
147+
148+
# Handle reasoning models (gpt-5, o1, o3, etc.) that put response in reasoning field
149+
if not text_response and 'reasoning' in message:
150+
# For simple tasks, try to extract the final answer from reasoning
151+
reasoning = message.get('reasoning', '')
152+
# If the model ran out of tokens, reasoning might contain a partial answer
153+
if reasoning:
154+
pp(f"Note: Using reasoning field (model: {model_name}, content was empty)")
155+
text_response = reasoning
156+
146157
usage = data.get("usage", {})
147158
prompt_tokens = usage.get("prompt_tokens", "N/A")
148159
response_tokens = usage.get("completion_tokens", "N/A")
@@ -885,6 +896,9 @@ def main():
885896
}
886897

887898
save_to_history(result['full_prompt'], response_text, result, files_info, args)
899+
else:
900+
# API call failed - exit with error code
901+
sys.exit(1)
888902
return
889903

890904
# If no prompt but file content exists, copy to clipboard

tests/integration/test_auto_commit.py

Lines changed: 26 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -225,23 +225,25 @@ def test_main_no_changes_exits_0(self, mock_git_repo, monkeypatch, capsys):
225225
assert "No changes to commit" in captured.out
226226

227227
def test_main_prompt_not_found_exits_1(self, mock_git_repo, monkeypatch, capsys):
228-
"""Exits with error if ab-prompt not found."""
228+
"""Exits with error if API call fails."""
229229
monkeypatch.chdir(mock_git_repo)
230230

231231
# Create changes
232232
(mock_git_repo / "test.txt").write_text("content")
233233
subprocess.run(["git", "add", "."], cwd=mock_git_repo, check=True)
234234

235-
monkeypatch.setattr(sys, "argv", ["auto-commit"])
235+
monkeypatch.setattr(sys, "argv", ["auto-commit", "-y"])
236236

237-
# Mock find_prompt_command to raise FileNotFoundError
238-
with patch("ab_cli.commands.auto_commit.find_prompt_command") as mock_find:
239-
mock_find.side_effect = FileNotFoundError("Could not find ab-prompt")
237+
# Mock send_to_openrouter to return None (API failure)
238+
# Also mock is_protected_branch to avoid input() prompt
239+
with patch("ab_cli.commands.auto_commit.send_to_openrouter") as mock_send:
240+
with patch("ab_cli.commands.auto_commit.is_protected_branch", return_value=False):
241+
mock_send.return_value = None
240242

241-
with pytest.raises(SystemExit) as exc_info:
242-
main()
243+
with pytest.raises(SystemExit) as exc_info:
244+
main()
243245

244-
assert exc_info.value.code == 1
246+
assert exc_info.value.code == 1
245247

246248
def test_main_auto_add_flag(self, mock_git_repo, monkeypatch, mock_input):
247249
"""'-a' flag stages all files."""
@@ -253,26 +255,27 @@ def test_main_auto_add_flag(self, mock_git_repo, monkeypatch, mock_input):
253255
# Verify file is not staged initially
254256
assert "unstaged.txt" not in get_staged_files()
255257

256-
monkeypatch.setattr(sys, "argv", ["auto-commit", "-a"])
258+
monkeypatch.setattr(sys, "argv", ["auto-commit", "-a", "-y"])
257259

258-
# Mock find_prompt_command to raise after staging happens
260+
# Mock send_to_openrouter to fail after staging happens
259261
call_count = [0]
260262
original_stage = stage_all_files
261263

262264
def mock_stage():
263265
original_stage()
264266
call_count[0] += 1
265267

268+
# Also mock is_protected_branch to avoid input() prompt
266269
with patch("ab_cli.commands.auto_commit.stage_all_files", side_effect=mock_stage):
267-
with patch("ab_cli.commands.auto_commit.find_prompt_command") as mock_find:
268-
mock_find.side_effect = FileNotFoundError("abort test")
270+
with patch("ab_cli.commands.auto_commit.send_to_openrouter") as mock_send:
271+
with patch("ab_cli.commands.auto_commit.is_protected_branch", return_value=False):
272+
mock_send.return_value = None # Fail after staging
269273

270-
with pytest.raises(SystemExit):
271-
main()
274+
with pytest.raises(SystemExit):
275+
main()
272276

273277
# Verify staging was called (the flag was honored)
274-
# Even though it fails later, we verified the -a flag triggers staging
275-
assert call_count[0] >= 0 # Test passes if we got here without other errors
278+
assert call_count[0] >= 1
276279

277280
def test_main_user_cancels(self, mock_git_repo, monkeypatch, capsys):
278281
"""Handles user cancellation during staging prompt."""
@@ -297,13 +300,15 @@ def test_main_lang_flag(self, mock_git_repo, monkeypatch, capsys):
297300
(mock_git_repo / "test.txt").write_text("content")
298301
subprocess.run(["git", "add", "."], cwd=mock_git_repo, check=True)
299302

300-
monkeypatch.setattr(sys, "argv", ["auto-commit", "-l", "pt-br"])
303+
monkeypatch.setattr(sys, "argv", ["auto-commit", "-l", "pt-br", "-y"])
301304

302-
with patch("ab_cli.commands.auto_commit.find_prompt_command") as mock_find:
303-
mock_find.side_effect = FileNotFoundError("abort")
305+
# Also mock is_protected_branch to avoid input() prompt
306+
with patch("ab_cli.commands.auto_commit.send_to_openrouter") as mock_send:
307+
with patch("ab_cli.commands.auto_commit.is_protected_branch", return_value=False):
308+
mock_send.return_value = None # Fail to abort
304309

305-
with pytest.raises(SystemExit):
306-
main()
310+
with pytest.raises(SystemExit):
311+
main()
307312

308313
captured = capsys.readouterr()
309314
# Language should be in info output

tests/unit/test_utils.py

Lines changed: 0 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,52 +1,9 @@
11
"""Unit tests for utility functions across ab_cli modules."""
2-
from pathlib import Path
32
from unittest.mock import patch
43

54
import pytest
65

76

8-
class TestAutoCommitUtils:
9-
"""Tests for utility functions in auto_commit module."""
10-
11-
def test_find_prompt_command_in_bin(self, tmp_path, monkeypatch):
12-
"""find_prompt_command finds ab-prompt in bin directory."""
13-
from ab_cli.commands import auto_commit
14-
15-
# Create mock bin structure
16-
bin_dir = tmp_path / "bin"
17-
bin_dir.mkdir()
18-
prompt_cmd = bin_dir / "ab-prompt"
19-
prompt_cmd.touch()
20-
21-
# Patch __file__ to point to our tmp structure
22-
fake_module_path = tmp_path / "src" / "ab_cli" / "commands" / "auto_commit.py"
23-
fake_module_path.parent.mkdir(parents=True, exist_ok=True)
24-
fake_module_path.touch()
25-
26-
with patch.object(auto_commit, "__file__", str(fake_module_path)):
27-
result = auto_commit.find_prompt_command()
28-
assert result == str(prompt_cmd)
29-
30-
def test_find_prompt_command_in_path(self, monkeypatch):
31-
"""find_prompt_command falls back to PATH."""
32-
from ab_cli.commands import auto_commit
33-
34-
# Mock pathlib to return non-existent path
35-
with patch.object(Path, "exists", return_value=False):
36-
with patch("shutil.which", return_value="/usr/local/bin/ab-prompt"):
37-
result = auto_commit.find_prompt_command()
38-
assert result == "ab-prompt"
39-
40-
def test_find_prompt_command_not_found(self, monkeypatch):
41-
"""find_prompt_command raises when not found."""
42-
from ab_cli.commands import auto_commit
43-
44-
with patch.object(Path, "exists", return_value=False):
45-
with patch("shutil.which", return_value=None):
46-
with pytest.raises(FileNotFoundError):
47-
auto_commit.find_prompt_command()
48-
49-
507
class TestAutoCommitGitHelpers:
518
"""Tests for git helper functions in auto_commit."""
529

0 commit comments

Comments
 (0)