diff --git a/aider_gitea/__init__.py b/aider_gitea/__init__.py index 52187de..8fd49ec 100644 --- a/aider_gitea/__init__.py +++ b/aider_gitea/__init__.py @@ -165,6 +165,18 @@ Go ahead with the changes you deem appropriate without waiting for explicit appr Do not draft changes beforehand; produce changes only once prompted for a specific file. """ +CLAUDE_CODE_MESSAGE_FORMAT = """{issue} + +Please fix this issue by making the necessary code changes. Follow these guidelines: +1. Run tests after making changes to ensure they pass +2. Follow existing code style and conventions +3. Make minimal, focused changes to address the issue +4. Commit your changes with a descriptive message + +The test command for this project is: {test_command} +The lint command for this project is: {lint_command} +""" + CODE_MODEL = None EVALUATOR_MODEL = 'ollama/gemma3:27b' @@ -174,6 +186,23 @@ MODEL_EDIT_MODES = { } +def run_post_solver_cleanup(repository_path: Path, solver_name: str) -> None: + """Run standard code quality fixes and commit changes after a code solver. + + Args: + repository_path: Path to the repository + solver_name: Name of the solver (for commit message) + """ + # Auto-fix standard code quality stuff + run_cmd(['bash', '-c', RUFF_FORMAT_AND_AUTO_FIX], repository_path, check=False) + run_cmd(['git', 'add', '.'], repository_path) + run_cmd( + ['git', 'commit', '-m', f'Ruff after {solver_name}'], + repository_path, + check=False, + ) + + @dataclasses.dataclass(frozen=True) class CodeSolverStrategy: """Base interface for code solving strategies.""" @@ -258,18 +287,109 @@ class AiderCodeSolver(CodeSolverStrategy): if not aider_did_not_crash: return aider_did_not_crash - # Auto-fix standard code quality stuff after aider - run_cmd(['bash', '-c', RUFF_FORMAT_AND_AUTO_FIX], repository_path, check=False) - run_cmd(['git', 'add', '.'], repository_path) - run_cmd( - ['git', 'commit', '-m', 'Ruff after aider'], - repository_path, + # Run post-solver cleanup + run_post_solver_cleanup(repository_path, 'aider') + + return True + + +@dataclasses.dataclass(frozen=True) +class ClaudeCodeSolver(CodeSolverStrategy): + """Code solver that uses Claude Code for issue resolution.""" + + def _create_claude_command(self, issue: str) -> list[str]: + """Create the Claude Code command for programmatic use.""" + cmd = [ + 'claude', + '-p', + '--output-format', + 'json', + '--max-turns', + '10', + ] + + if CODE_MODEL: + cmd.extend(['--model', CODE_MODEL]) + + cmd.append(issue) + return cmd + + def solve_issue_round(self, repository_path: Path, issue_content: str) -> bool: + """Solve an issue using Claude Code.""" + import json + import os + + # Set Anthropic API key environment variable + env = os.environ.copy() + env['ANTHROPIC_API_KEY'] = secrets.anthropic_api_key() + + # Prepare the issue prompt for Claude Code + enhanced_issue = CLAUDE_CODE_MESSAGE_FORMAT.format( + issue=issue_content, + test_command=AIDER_TEST, + lint_command=AIDER_LINT, + ) + + # Create Claude Code command + claude_command = self._create_claude_command(enhanced_issue) + + # Run Claude Code + result = subprocess.run( + claude_command, + cwd=repository_path, + env=env, + capture_output=True, + text=True, check=False, ) + if result.returncode != 0: + logger.error('Claude Code failed with return code %d', result.returncode) + logger.error('stderr: %s', result.stderr) + return False + + # Parse response if it's JSON + try: + if result.stdout.strip(): + response_data = json.loads(result.stdout) + logger.info( + 'Claude Code response: %s', + response_data.get('text', 'No text field'), + ) + except json.JSONDecodeError: + logger.info('Claude Code response (non-JSON): %s', result.stdout[:500]) + + # Run post-solver cleanup + run_post_solver_cleanup(repository_path, 'Claude Code') + return True +def is_anthropic_model(model: str) -> bool: + """Check if the model string indicates an Anthropic/Claude model.""" + if not model: + return False + + anthropic_indicators = [ + 'claude', + 'anthropic', + 'sonnet', + 'haiku', + 'opus', + ] + + model_lower = model.lower() + return any(indicator in model_lower for indicator in anthropic_indicators) + + +def create_code_solver() -> CodeSolverStrategy: + """Create the appropriate code solver based on the configured model.""" + if is_anthropic_model(CODE_MODEL): + return ClaudeCodeSolver() + else: + return AiderCodeSolver() + + def get_commit_messages(cwd: Path, base_branch: str, current_branch: str) -> list[str]: """Get commit messages between base branch and current branch. @@ -572,7 +692,7 @@ def solve_issues_in_repository( logger.info('Skipping already processed issue #%s: %s', issue_number, title) else: branch_name = generate_branch_name(issue_number, title) - code_solver = AiderCodeSolver() + code_solver = create_code_solver() with tempfile.TemporaryDirectory() as repository_path: issue_resolution = solve_issue_in_repository( repository_config, diff --git a/aider_gitea/secrets.py b/aider_gitea/secrets.py index 1611634..f82325a 100644 --- a/aider_gitea/secrets.py +++ b/aider_gitea/secrets.py @@ -9,3 +9,7 @@ def llm_api_keys() -> list[str]: def gitea_token() -> str: return SECRETS.load_or_fail('GITEA_TOKEN') + + +def anthropic_api_key() -> str: + return SECRETS.load_or_fail('ANTHROPIC_API_KEY') diff --git a/test/test_claude_code_integration.py b/test/test_claude_code_integration.py new file mode 100644 index 0000000..9229225 --- /dev/null +++ b/test/test_claude_code_integration.py @@ -0,0 +1,122 @@ +import pytest + +from aider_gitea import ( + AIDER_LINT, + AIDER_TEST, + CLAUDE_CODE_MESSAGE_FORMAT, + AiderCodeSolver, + ClaudeCodeSolver, + create_code_solver, + is_anthropic_model, +) + + +class TestClaudeCodeIntegration: + """Test Claude Code integration and model routing logic.""" + + def test_is_anthropic_model_detection(self): + """Test that Anthropic models are correctly detected.""" + # Anthropic models should return True + assert is_anthropic_model('claude-3-sonnet') + assert is_anthropic_model('claude-3-haiku') + assert is_anthropic_model('claude-3-opus') + assert is_anthropic_model('anthropic/claude-3-sonnet') + assert is_anthropic_model('Claude-3-Sonnet') # Case insensitive + assert is_anthropic_model('ANTHROPIC/CLAUDE') + assert is_anthropic_model('some-sonnet-model') + assert is_anthropic_model('haiku-variant') + + # Non-Anthropic models should return False + assert not is_anthropic_model('gpt-4') + assert not is_anthropic_model('gpt-3.5-turbo') + assert not is_anthropic_model('ollama/llama') + assert not is_anthropic_model('gemini-pro') + assert not is_anthropic_model('mistral-7b') + assert not is_anthropic_model('') + assert not is_anthropic_model(None) + + def test_create_code_solver_routing(self, monkeypatch): + """Test that the correct solver is created based on model.""" + import aider_gitea + + # Test Anthropic model routing + monkeypatch.setattr(aider_gitea, 'CODE_MODEL', 'claude-3-sonnet') + solver = create_code_solver() + assert isinstance(solver, ClaudeCodeSolver) + + # Test non-Anthropic model routing + monkeypatch.setattr(aider_gitea, 'CODE_MODEL', 'gpt-4') + solver = create_code_solver() + assert isinstance(solver, AiderCodeSolver) + + # Test None model routing (should default to Aider) + monkeypatch.setattr(aider_gitea, 'CODE_MODEL', None) + solver = create_code_solver() + assert isinstance(solver, AiderCodeSolver) + + def test_claude_code_solver_command_creation(self): + """Test that Claude Code commands are created correctly.""" + import aider_gitea + + solver = ClaudeCodeSolver() + issue = 'Fix the bug in the code' + + # Test without model + with pytest.MonkeyPatch().context() as m: + m.setattr(aider_gitea, 'CODE_MODEL', None) + cmd = solver._create_claude_command(issue) + expected = [ + 'claude', + '-p', + '--output-format', + 'json', + '--max-turns', + '10', + issue, + ] + assert cmd == expected + + # Test with model + with pytest.MonkeyPatch().context() as m: + m.setattr(aider_gitea, 'CODE_MODEL', 'claude-3-sonnet') + cmd = solver._create_claude_command(issue) + expected = [ + 'claude', + '-p', + '--output-format', + 'json', + '--max-turns', + '10', + '--model', + 'claude-3-sonnet', + issue, + ] + assert cmd == expected + + def test_claude_code_message_format(self): + """Test that Claude Code message format works correctly.""" + issue_content = 'Fix the authentication bug' + + formatted_message = CLAUDE_CODE_MESSAGE_FORMAT.format( + issue=issue_content, + test_command=AIDER_TEST, + lint_command=AIDER_LINT, + ) + + # Verify the issue content is included + assert issue_content in formatted_message + + # Verify the test and lint commands are included + assert AIDER_TEST in formatted_message + assert AIDER_LINT in formatted_message + + # Verify the guidelines are present + assert 'Run tests after making changes' in formatted_message + assert 'Follow existing code style' in formatted_message + assert 'Make minimal, focused changes' in formatted_message + assert 'Commit your changes' in formatted_message + + # Verify the structure contains placeholders that got replaced + assert '{issue}' not in formatted_message + assert '{test_command}' not in formatted_message + assert '{lint_command}' not in formatted_message