Refactor Aider integration using Strategy Pattern

Extracted Aider-specific functionality into AiderCodeSolver strategy class to enable future integration with Claude Code. This architectural change maintains backward compatibility while preparing for multi-AI-assistant support.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Jon Michael Aanes 2025-06-09 01:02:45 +02:00
parent c476b1a37a
commit 0b9902c428
2 changed files with 120 additions and 79 deletions

View File

@ -134,13 +134,13 @@ def bash_cmd(*commands: str) -> str:
AIDER_TEST = bash_cmd( AIDER_TEST = bash_cmd(
'echo "Setting up virtual environment"' 'echo "Setting up virtual environment"',
'virtualenv venv', 'virtualenv venv',
'echo "Activating virtual environment"' 'echo "Activating virtual environment"',
'source venv/bin/activate', 'source venv/bin/activate',
'echo "Installing package"' 'echo "Installing package"',
'pip install -e .', 'pip install -e .',
'echo "Testing package"' 'echo "Testing package"',
'pytest test', 'pytest test',
) )
@ -174,14 +174,35 @@ MODEL_EDIT_MODES = {
} }
def create_aider_command(issue: str) -> list[str]: @dataclasses.dataclass(frozen=True)
class CodeSolverStrategy:
"""Base interface for code solving strategies."""
def solve_issue_round(self, repository_path: Path, issue_content: str) -> bool:
"""Attempt to solve an issue in a single round.
Args:
repository_path: Path to the repository
issue_content: The issue description to solve
Returns:
True if the solution round completed without crashing, False otherwise
"""
raise NotImplementedError
@dataclasses.dataclass(frozen=True)
class AiderCodeSolver(CodeSolverStrategy):
"""Code solver that uses Aider for issue resolution."""
def _create_aider_command(self, issue: str) -> list[str]:
"""Create the Aider command with all necessary flags."""
l = [ l = [
'aider', 'aider',
'--chat-language', '--chat-language',
'english', 'english',
'--no-stream', '--no-stream',
'--no-analytics', '--no-analytics',
#'--no-check-update',
'--test-cmd', '--test-cmd',
AIDER_TEST, AIDER_TEST,
'--lint-cmd', '--lint-cmd',
@ -225,6 +246,29 @@ def create_aider_command(issue: str) -> list[str]:
return l return l
def solve_issue_round(self, repository_path: Path, issue_content: str) -> bool:
"""Solve an issue using Aider."""
# Primary Aider command
aider_command = self._create_aider_command(issue_content)
aider_did_not_crash = run_cmd(
aider_command,
repository_path,
check=False,
)
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,
check=False,
)
return True
def get_commit_messages(cwd: Path, base_branch: str, current_branch: str) -> list[str]: def get_commit_messages(cwd: Path, base_branch: str, current_branch: str) -> list[str]:
"""Get commit messages between base branch and current branch. """Get commit messages between base branch and current branch.
@ -345,33 +389,17 @@ def run_cmd(cmd: list[str], cwd: Path | None = None, check=True) -> bool:
return result.returncode == 0 return result.returncode == 0
def issue_solution_round(repository_path, issue_content):
# Primary Aider command
aider_command = create_aider_command(issue_content)
aider_did_not_crash = run_cmd(
aider_command,
repository_path,
check=False,
)
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, check=False)
return True
def remove_thinking_tokens(text: str) -> str: def remove_thinking_tokens(text: str) -> str:
text = re.sub(r'^\s*<think>.*?</think>', '', text, flags=re.MULTILINE | re.DOTALL) text = re.sub(r'^\s*<think>.*?</think>', '', text, flags=re.MULTILINE | re.DOTALL)
text = text.strip() text = text.strip()
return text return text
assert remove_thinking_tokens('<think>Hello</think>\nWorld\n') == 'World' assert remove_thinking_tokens('<think>Hello</think>\nWorld\n') == 'World'
assert remove_thinking_tokens('<think>\nHello\n</think>\nWorld\n') == 'World' assert remove_thinking_tokens('<think>\nHello\n</think>\nWorld\n') == 'World'
assert remove_thinking_tokens('\n<think>\nHello\n</think>\nWorld\n') == 'World' assert remove_thinking_tokens('\n<think>\nHello\n</think>\nWorld\n') == 'World'
def run_ollama(cwd: Path, texts: list[str]) -> str: def run_ollama(cwd: Path, texts: list[str]) -> str:
cmd = ['ollama', 'run', EVALUATOR_MODEL.removeprefix('ollama/')] cmd = ['ollama', 'run', EVALUATOR_MODEL.removeprefix('ollama/')]
process = subprocess.Popen( process = subprocess.Popen(
@ -397,9 +425,11 @@ def parse_yes_no_answer(text: str) -> bool | None:
return False return False
return None return None
assert parse_yes_no_answer('Yes.') == True assert parse_yes_no_answer('Yes.') == True
assert parse_yes_no_answer('no') == False assert parse_yes_no_answer('no') == False
def run_ollama_and_get_yes_or_no(cwd, initial_texts: list[str]) -> bool: def run_ollama_and_get_yes_or_no(cwd, initial_texts: list[str]) -> bool:
texts = list(initial_texts) texts = list(initial_texts)
texts.append('Think through your answer.') texts.append('Think through your answer.')
@ -455,6 +485,7 @@ def solve_issue_in_repository(
issue_description: str, issue_description: str,
issue_number: str, issue_number: str,
gitea_client, gitea_client,
code_solver: CodeSolverStrategy,
) -> IssueResolution: ) -> IssueResolution:
logger.info('### %s #####', issue_title) logger.info('### %s #####', issue_title)
@ -464,28 +495,31 @@ def solve_issue_in_repository(
run_cmd(['git', 'checkout', repository_config.base_branch], repository_path) run_cmd(['git', 'checkout', repository_config.base_branch], repository_path)
run_cmd(['git', 'checkout', '-b', branch_name], repository_path) run_cmd(['git', 'checkout', '-b', branch_name], repository_path)
# Run initial ruff pass before aider # Run initial ruff pass before code solver
run_cmd(['bash', '-c', RUFF_FORMAT_AND_AUTO_FIX], repository_path, check=False) run_cmd(['bash', '-c', RUFF_FORMAT_AND_AUTO_FIX], repository_path, check=False)
run_cmd(['git', 'add', '.'], repository_path) run_cmd(['git', 'add', '.'], repository_path)
run_cmd(['git', 'commit', '-m', 'Initial ruff pass'], repository_path, check=False) run_cmd(['git', 'commit', '-m', 'Initial ruff pass'], repository_path, check=False)
# Run aider # Run code solver
issue_content = f'# {issue_title}\n{issue_description}' issue_content = f'# {issue_title}\n{issue_description}'
while True: while True:
# Save the commit hash after ruff but before aider # Save the commit hash after ruff but before code solver
pre_aider_commit = get_head_commit_hash(repository_path) pre_aider_commit = get_head_commit_hash(repository_path)
# Run aider # Run code solver
aider_did_not_crash = issue_solution_round(repository_path, issue_content) solver_did_not_crash = code_solver.solve_issue_round(
if not aider_did_not_crash: repository_path,
logger.error('Aider invocation failed for issue #%s', issue_number) issue_content,
)
if not solver_did_not_crash:
logger.error('Code solver invocation failed for issue #%s', issue_number)
return IssueResolution(False) return IssueResolution(False)
# Check if aider made any changes beyond the initial ruff pass # Check if solver made any changes beyond the initial ruff pass
if not has_commits_on_branch(repository_path, pre_aider_commit, 'HEAD'): if not has_commits_on_branch(repository_path, pre_aider_commit, 'HEAD'):
logger.error( logger.error(
'Aider did not make any changes beyond the initial ruff pass for issue #%s', 'Code solver did not make any changes beyond the initial ruff pass for issue #%s',
issue_number, issue_number,
) )
return IssueResolution(False) return IssueResolution(False)
@ -538,6 +572,7 @@ def solve_issues_in_repository(
logger.info('Skipping already processed issue #%s: %s', issue_number, title) logger.info('Skipping already processed issue #%s: %s', issue_number, title)
else: else:
branch_name = generate_branch_name(issue_number, title) branch_name = generate_branch_name(issue_number, title)
code_solver = AiderCodeSolver()
with tempfile.TemporaryDirectory() as repository_path: with tempfile.TemporaryDirectory() as repository_path:
issue_resolution = solve_issue_in_repository( issue_resolution = solve_issue_in_repository(
repository_config, repository_config,
@ -547,6 +582,7 @@ def solve_issues_in_repository(
issue_description, issue_description,
issue_number, issue_number,
client, client,
code_solver,
) )
seen_issues_db.mark_as_seen(issue_url, str(issue_number)) seen_issues_db.mark_as_seen(issue_url, str(issue_number))
seen_issues_db.update_pr_info( seen_issues_db.update_pr_info(
@ -571,6 +607,7 @@ def solve_issues_in_repository(
client, client,
seen_issues_db, seen_issues_db,
issue_url, issue_url,
code_solver,
) )
# Handle failing pipelines # Handle failing pipelines
@ -580,6 +617,7 @@ def solve_issues_in_repository(
branch_name, branch_name,
Path(repository_path), Path(repository_path),
client, client,
code_solver,
) )
@ -591,8 +629,9 @@ def handle_pr_comments(
client, client,
seen_issues_db, seen_issues_db,
issue_url, issue_url,
code_solver: CodeSolverStrategy,
): ):
"""Fetch unresolved PR comments and resolve them via aider.""" """Fetch unresolved PR comments and resolve them via code solver."""
comments = client.get_pull_request_comments( comments = client.get_pull_request_comments(
repository_config.owner, repository_config.owner,
repository_config.repo, repository_config.repo,
@ -614,8 +653,8 @@ def handle_pr_comments(
f'Resolve the following reviewer comment:\n{body}\n\n' f'Resolve the following reviewer comment:\n{body}\n\n'
f'File: {path}\n\nContext:\n{context}' f'File: {path}\n\nContext:\n{context}'
) )
# invoke aider on the comment context # invoke code solver on the comment context
issue_solution_round(repository_path, issue) code_solver.solve_issue_round(repository_path, issue)
# commit and push changes for this comment # commit and push changes for this comment
run_cmd(['git', 'add', path], repository_path, check=False) run_cmd(['git', 'add', path], repository_path, check=False)
run_cmd( run_cmd(
@ -632,8 +671,9 @@ def handle_failing_pipelines(
branch_name: str, branch_name: str,
repository_path: Path, repository_path: Path,
client, client,
code_solver: CodeSolverStrategy,
) -> None: ) -> None:
"""Fetch failing pipelines for the given PR and resolve them via aider.""" """Fetch failing pipelines for the given PR and resolve them via code solver."""
while True: while True:
failed_runs = client.get_failed_pipelines( failed_runs = client.get_failed_pipelines(
repository_config.owner, repository_config.owner,
@ -651,7 +691,7 @@ def handle_failing_pipelines(
lines = log.strip().split('\n') lines = log.strip().split('\n')
context = '\n'.join(lines[-100:]) context = '\n'.join(lines[-100:])
issue = f'Resolve the following failing pipeline run {run_id}:\n\n{context}' issue = f'Resolve the following failing pipeline run {run_id}:\n\n{context}'
issue_solution_round(repository_path, issue) code_solver.solve_issue_round(repository_path, issue)
run_cmd(['git', 'add', '.'], repository_path, check=False) run_cmd(['git', 'add', '.'], repository_path, check=False)
run_cmd( run_cmd(
['git', 'commit', '-m', f'Resolve pipeline {run_id}'], ['git', 'commit', '-m', f'Resolve pipeline {run_id}'],

View File

@ -109,6 +109,7 @@ def find_python_packages() -> list[str]:
print(f'Found following packages: {packages}') print(f'Found following packages: {packages}')
return sorted(packages) return sorted(packages)
with open(PACKAGE_NAME + '/_version.py') as f: with open(PACKAGE_NAME + '/_version.py') as f:
version = parse_version_file(f.read()) version = parse_version_file(f.read())