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(
'echo "Setting up virtual environment"'
'echo "Setting up virtual environment"',
'virtualenv venv',
'echo "Activating virtual environment"'
'echo "Activating virtual environment"',
'source venv/bin/activate',
'echo "Installing package"'
'echo "Installing package"',
'pip install -e .',
'echo "Testing package"'
'echo "Testing package"',
'pytest test',
)
@ -169,61 +169,105 @@ CODE_MODEL = None
EVALUATOR_MODEL = 'ollama/gemma3:27b'
MODEL_EDIT_MODES = {
'ollama/qwen3:32b': 'diff',
'ollama/hf.co/unsloth/Qwen3-30B-A3B-GGUF:Q4_K_M': 'diff',
'ollama/qwen3:32b': 'diff',
'ollama/hf.co/unsloth/Qwen3-30B-A3B-GGUF:Q4_K_M': 'diff',
}
def create_aider_command(issue: str) -> list[str]:
l = [
'aider',
'--chat-language',
'english',
'--no-stream',
'--no-analytics',
#'--no-check-update',
'--test-cmd',
AIDER_TEST,
'--lint-cmd',
AIDER_LINT,
'--auto-test',
'--no-auto-lint',
'--yes',
'--disable-playwright',
'--timeout',
str(10_000),
]
@dataclasses.dataclass(frozen=True)
class CodeSolverStrategy:
"""Base interface for code solving strategies."""
if edit_format := MODEL_EDIT_MODES.get(CODE_MODEL):
l.append('--edit-format')
l.append(edit_format)
del edit_format
def solve_issue_round(self, repository_path: Path, issue_content: str) -> bool:
"""Attempt to solve an issue in a single round.
for key in secrets.llm_api_keys():
l += ['--api-key', key]
Args:
repository_path: Path to the repository
issue_content: The issue description to solve
if False:
l.append('--read')
l.append('CONVENTIONS.md')
Returns:
True if the solution round completed without crashing, False otherwise
"""
raise NotImplementedError
if True:
l.append('--cache-prompts')
if False:
l.append('--architect')
@dataclasses.dataclass(frozen=True)
class AiderCodeSolver(CodeSolverStrategy):
"""Code solver that uses Aider for issue resolution."""
if CODE_MODEL:
l.append('--model')
l.append(CODE_MODEL)
def _create_aider_command(self, issue: str) -> list[str]:
"""Create the Aider command with all necessary flags."""
l = [
'aider',
'--chat-language',
'english',
'--no-stream',
'--no-analytics',
'--test-cmd',
AIDER_TEST,
'--lint-cmd',
AIDER_LINT,
'--auto-test',
'--no-auto-lint',
'--yes',
'--disable-playwright',
'--timeout',
str(10_000),
]
if CODE_MODEL.startswith('ollama/') and False:
l.append('--auto-lint')
if edit_format := MODEL_EDIT_MODES.get(CODE_MODEL):
l.append('--edit-format')
l.append(edit_format)
del edit_format
if True:
l.append('--message')
l.append(LLM_MESSAGE_FORMAT.format(issue=issue))
for key in secrets.llm_api_keys():
l += ['--api-key', key]
return l
if False:
l.append('--read')
l.append('CONVENTIONS.md')
if True:
l.append('--cache-prompts')
if False:
l.append('--architect')
if CODE_MODEL:
l.append('--model')
l.append(CODE_MODEL)
if CODE_MODEL.startswith('ollama/') and False:
l.append('--auto-lint')
if True:
l.append('--message')
l.append(LLM_MESSAGE_FORMAT.format(issue=issue))
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]:
@ -345,33 +389,17 @@ def run_cmd(cmd: list[str], cwd: Path | None = None, check=True) -> bool:
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:
text = re.sub(r'^\s*<think>.*?</think>', '', text, flags=re.MULTILINE | re.DOTALL)
text = text.strip()
return text
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('\n<think>\nHello\n</think>\nWorld\n') == 'World'
def run_ollama(cwd: Path, texts: list[str]) -> str:
cmd = ['ollama', 'run', EVALUATOR_MODEL.removeprefix('ollama/')]
process = subprocess.Popen(
@ -397,9 +425,11 @@ def parse_yes_no_answer(text: str) -> bool | None:
return False
return None
assert parse_yes_no_answer('Yes.') == True
assert parse_yes_no_answer('no') == False
def run_ollama_and_get_yes_or_no(cwd, initial_texts: list[str]) -> bool:
texts = list(initial_texts)
texts.append('Think through your answer.')
@ -455,6 +485,7 @@ def solve_issue_in_repository(
issue_description: str,
issue_number: str,
gitea_client,
code_solver: CodeSolverStrategy,
) -> IssueResolution:
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', '-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(['git', 'add', '.'], repository_path)
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}'
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)
# Run aider
aider_did_not_crash = issue_solution_round(repository_path, issue_content)
if not aider_did_not_crash:
logger.error('Aider invocation failed for issue #%s', issue_number)
# Run code solver
solver_did_not_crash = code_solver.solve_issue_round(
repository_path,
issue_content,
)
if not solver_did_not_crash:
logger.error('Code solver invocation failed for issue #%s', issue_number)
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'):
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,
)
return IssueResolution(False)
@ -538,6 +572,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()
with tempfile.TemporaryDirectory() as repository_path:
issue_resolution = solve_issue_in_repository(
repository_config,
@ -547,6 +582,7 @@ def solve_issues_in_repository(
issue_description,
issue_number,
client,
code_solver,
)
seen_issues_db.mark_as_seen(issue_url, str(issue_number))
seen_issues_db.update_pr_info(
@ -571,6 +607,7 @@ def solve_issues_in_repository(
client,
seen_issues_db,
issue_url,
code_solver,
)
# Handle failing pipelines
@ -580,6 +617,7 @@ def solve_issues_in_repository(
branch_name,
Path(repository_path),
client,
code_solver,
)
@ -591,8 +629,9 @@ def handle_pr_comments(
client,
seen_issues_db,
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(
repository_config.owner,
repository_config.repo,
@ -614,8 +653,8 @@ def handle_pr_comments(
f'Resolve the following reviewer comment:\n{body}\n\n'
f'File: {path}\n\nContext:\n{context}'
)
# invoke aider on the comment context
issue_solution_round(repository_path, issue)
# invoke code solver on the comment context
code_solver.solve_issue_round(repository_path, issue)
# commit and push changes for this comment
run_cmd(['git', 'add', path], repository_path, check=False)
run_cmd(
@ -632,8 +671,9 @@ def handle_failing_pipelines(
branch_name: str,
repository_path: Path,
client,
code_solver: CodeSolverStrategy,
) -> 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:
failed_runs = client.get_failed_pipelines(
repository_config.owner,
@ -651,7 +691,7 @@ def handle_failing_pipelines(
lines = log.strip().split('\n')
context = '\n'.join(lines[-100:])
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', '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}')
return sorted(packages)
with open(PACKAGE_NAME + '/_version.py') as f:
version = parse_version_file(f.read())