Compare commits

..

No commits in common. "main" and "issue-2" have entirely different histories.

21 changed files with 147 additions and 1337 deletions

View File

@ -1,18 +0,0 @@
name: Build Python Container
on:
push:
tags:
- 'v[0-9]+.[0-9]+.[0-9]+'
paths-ignore: ['README.md', '.gitignore', 'LICENSE', 'CONVENTIONS.md', 'ruff.toml']
jobs:
Package-Container:
uses: jmaa/workflows/.gitea/workflows/container.yaml@v6.21
with:
REGISTRY_DOMAIN: gitfub.space
REGISTRY_ORGANIZATION: jmaa
secrets:
DOCKER_USERNAME: ${{ secrets.PIPY_REPO_USER }}
DOCKER_PASSWORD: ${{ secrets.PIPY_REPO_PASS }}
PIPELINE_WORKER_SSH_KEY: ${{ secrets.PIPELINE_WORKER_SSH_KEY }}
PIPELINE_WORKER_KNOWN_HOSTS: ${{ secrets.PIPELINE_WORKER_KNOWN_HOSTS }}

View File

@ -1,10 +1,3 @@
# Conventions
When contributing code to this project, you MUST follow the requirements
specified here.
## Code Conventions
When contributing code to this project, you MUST follow these principles: When contributing code to this project, you MUST follow these principles:
- Code should be easy to read and understand. - Code should be easy to read and understand.
@ -22,11 +15,3 @@ When contributing code to this project, you MUST follow these principles:
- Do not use f-strings in logging statements. - Do not use f-strings in logging statements.
- Loop variables and walrus-expression-variables should be deleted when - Loop variables and walrus-expression-variables should be deleted when
unneeded to keep scope clean, and to avoid accidental use. unneeded to keep scope clean, and to avoid accidental use.
## Testing
When contributing test to this project, you MUST follow these principles:
- Do not use any testing libraries other than `pytest`.
- Mocking is the root of all evil. Writing your own stubs is much more
preferable.

20
LICENSE
View File

@ -1,21 +1,3 @@
MIT License
Copyright (c) 2025 Jon Michael Aanes Copyright (c) 2025 Jon Michael Aanes
Permission is hereby granted, free of charge, to any person obtaining a copy All rights reserved.
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -4,89 +4,19 @@
# Aider Gitea #
![Test program/library](https://gitfub.space/Jmaa/aider-gitea/actions/workflows/python-test.yml/badge.svg) ![Test program/library](https://gitfub.space/Jmaa/aider-gitea/actions/workflows/python-test.yml/badge.svg)
A code automation tool that integrates Gitea with Aider to automatically solve issues.
This program monitors your [Gitea](https://about.gitea.com/) repository for issues with the 'aider' label.
When such an issue is found, it:
1. Creates a new branch.
2. Invokes [Aider](https://aider.chat/) to solve the issue using a Large-Language Model.
3. Runs tests and code quality checks.
4. Creates a pull request with the solution.
## Usage
An application token must be supplied for the `gitea_token` secret. This must
have the following permissions:
- `read:issue`: To be able to read issues on the specified repository.
- `write:repository`: To be able to create pull requests.
- `read:user`: Needed to iterate all user's repositories.
### Command Line
```bash
# Run with default settings
python -m aider_gitea
# Specify custom repository and owner
python -m aider_gitea --owner myorg --repo myproject
# Use a custom Gitea URL
python -m aider_gitea --gitea-url https://gitea.example.com
# Specify a different base branch
python -m aider_gitea --base-branch develop
```
### Python API
```python
from aider_gitea import solve_issue_in_repository
from pathlib import Path
# Solve an issue programmatically
args = argparse.Namespace(
gitea_url="https://gitea.example.com",
owner="myorg",
repo="myproject",
base_branch="main"
)
solve_issue_in_repository(
args,
Path("/path/to/repo"),
"issue-123-fix-bug",
"Fix critical bug",
"The application crashes when processing large files",
"123"
)
```
### Environment Configuration
The tool uses environment variables for sensitive information:
- `GITEA_TOKEN`: Your Gitea API token
- `LLM_API_KEY`: API key for the language model used by Aider
```
## Dependencies ## Dependencies
This project requires [Python](https://www.python.org/) 3.8 or newer. This project requires [Python](https://www.python.org/) 3.8 or newer.
All required libraries can be installed easily using: This project does not have any library requirements 😎
```bash
pip install -r requirements.txt
```
Full list of requirements:
- [secret_loader](https://gitfub.space/Jmaa/secret_loader)
## Contributing ## Contributing
@ -113,25 +43,7 @@ pytest --cov=aider_gitea test
## License ## License
``` ```
MIT License
Copyright (c) 2025 Jon Michael Aanes Copyright (c) 2025 Jon Michael Aanes
Permission is hereby granted, free of charge, to any person obtaining a copy All rights reserved.
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
``` ```

View File

@ -1,442 +1 @@
"""Aider Gitea. # TODO
A code automation tool that integrates Gitea with Aider to automatically solve issues.
This program monitors your [Gitea](https://about.gitea.com/) repository for issues with the 'aider' label.
When such an issue is found, it:
1. Creates a new branch.
2. Invokes [Aider](https://aider.chat/) to solve the issue using a Large-Language Model.
3. Runs tests and code quality checks.
4. Creates a pull request with the solution.
Inspired by [the AI workflows](https://github.com/oscoreio/ai-workflows/)
project.
## Usage
An application token must be supplied for the `gitea_token` secret. This must
have the following permissions:
- `read:issue`: To be able to read issues on the specified repository.
- `write:repository`: To be able to create pull requests.
- `read:user`: Needed to iterate all user's repositories.
### Command Line
```bash
# Run with default settings
python -m aider_gitea
# Specify custom repository and owner
python -m aider_gitea --owner myorg --repo myproject
# Use a custom Gitea URL
python -m aider_gitea --gitea-url https://gitea.example.com
# Specify a different base branch
python -m aider_gitea --base-branch develop
```
### Python API
```python
from aider_gitea import solve_issue_in_repository
from pathlib import Path
# Solve an issue programmatically
args = argparse.Namespace(
gitea_url="https://gitea.example.com",
owner="myorg",
repo="myproject",
base_branch="main"
)
solve_issue_in_repository(
args,
Path("/path/to/repo"),
"issue-123-fix-bug",
"Fix critical bug",
"The application crashes when processing large files",
"123"
)
```
### Environment Configuration
The tool uses environment variables for sensitive information:
- `GITEA_TOKEN`: Your Gitea API token
- `LLM_API_KEY`: API key for the language model used by Aider
```
"""
import dataclasses
import logging
import re
import subprocess
import sys
import tempfile
from pathlib import Path
from . import secrets
from ._version import __version__ # noqa: F401
logger = logging.getLogger(__name__)
@dataclasses.dataclass(frozen=True)
class RepositoryConfig:
gitea_url: str
owner: str
repo: str
base_branch: str
def repo_url(self) -> str:
return f'{self.gitea_url}:{self.owner}/{self.repo}.git'.replace(
'https://',
'git@',
)
@dataclasses.dataclass(frozen=True)
class IssueResolution:
success: bool
pull_request_url: str | None = None
pull_request_id: str | None = None
def generate_branch_name(issue_number: str, issue_title: str) -> str:
"""Create a branch name by sanitizing the issue title.
Non-alphanumeric characters (except spaces) are removed,
the text is lowercased, and spaces are replaced with dashes.
Args:
issue_number: The issue number to include in the branch name.
issue_title: The issue title to sanitize and include in the branch name.
Returns:
A sanitized branch name combining the issue number and title.
"""
sanitized = re.sub(r'[^0-9a-zA-Z ]+', '', issue_title)
parts = ['issue', str(issue_number), *sanitized.lower().split()]
return '-'.join(parts)
def bash_cmd(*commands: str) -> str:
commands = ('set -e', *commands)
return 'bash -c "' + ';'.join(commands) + '"'
AIDER_TEST = bash_cmd(
'virtualenv venv',
'source venv/bin/activate',
'pip install -e .',
'pytest test',
)
RUFF_FORMAT_AND_AUTO_FIX = bash_cmd(
'ruff format',
'ruff check --fix --ignore RUF022 --ignore PGH004',
'ruff format',
'ruff check --fix --ignore RUF022 --ignore PGH004',
)
AIDER_LINT = bash_cmd(
RUFF_FORMAT_AND_AUTO_FIX,
'ruff format',
'ruff check --ignore RUF022 --ignore PGH004',
)
LLM_MESSAGE_FORMAT = """
{issue}
# Solution Details
For code tasks:
1. Create a plan for how to solve the issue.
2. Write unit tests that proves that your solution works.
3. Then, solve the issue by writing the required code.
"""
MODEL = None
def create_aider_command(issue: str) -> list[str]:
l = [
'aider',
'--chat-language',
'english',
'--no-stream',
'--no-analytics',
'--test-cmd',
AIDER_TEST,
'--lint-cmd',
AIDER_LINT,
'--auto-test',
'--no-auto-lint',
'--read',
'CONVENTIONS.md',
'--message',
LLM_MESSAGE_FORMAT.format(issue=issue),
'--yes',
]
for key in secrets.llm_api_keys():
l += ['--api-key', key]
if True:
l.append('--cache-prompts')
if False:
l.append('--architect')
if MODEL:
l.append('--model')
l.append(MODEL)
return l
def get_commit_messages(cwd: Path, base_branch: str, current_branch: str) -> list[str]:
"""Get commit messages between base branch and current branch.
Args:
cwd: The current working directory (repository path).
base_branch: The name of the base branch to compare against.
current_branch: The name of the current branch to check for commits.
Returns:
A string containing all commit messages, one per line.
"""
try:
result = subprocess.run(
['git', 'log', f'{base_branch}..{current_branch}', '--pretty=format:%s'],
check=True,
cwd=cwd,
capture_output=True,
text=True,
)
return list(reversed(result.stdout.strip().split('\n')))
except subprocess.CalledProcessError:
logger.exception(f'Failed to get commit messages on branch {current_branch}')
return []
def push_changes(
repository_config: RepositoryConfig,
cwd: Path,
branch_name: str,
issue_number: str,
issue_title: str,
gitea_client,
) -> IssueResolution:
# Check if there are any commits on the branch before pushing
if not has_commits_on_branch(cwd, repository_config.base_branch, branch_name):
logger.info('No commits made on branch %s, skipping push', branch_name)
return IssueResolution(False)
# Get commit messages for PR description
commit_messages = get_commit_messages(
cwd, repository_config.base_branch, branch_name,
)
description = f'This pull request resolves #{issue_number}\n\n'
if commit_messages:
description += '## Commit Messages\n\n'
for message in commit_messages:
description += f'- {message}\n'
# First push the branch without creating a PR
cmd = ['git', 'push', 'origin', branch_name, '--force']
run_cmd(cmd, cwd)
# Then create the PR with the aider label
pr_response = gitea_client.create_pull_request(
owner=repository_config.owner,
repo=repository_config.repo,
title=issue_title,
body=description,
head=branch_name,
base=repository_config.base_branch,
labels=['aider'],
)
# Extract PR number and URL if available
return IssueResolution(
True, str(pr_response.get('number')), pr_response.get('html_url'),
)
def has_commits_on_branch(cwd: Path, base_branch: str, current_branch: str) -> bool:
"""Check if there are any commits on the current branch that aren't in the base branch.
Args:
cwd: The current working directory (repository path).
base_branch: The name of the base branch to compare against.
current_branch: The name of the current branch to check for commits.
Returns:
True if there are commits on the current branch not in the base branch, False otherwise.
"""
try:
commit_messages = get_commit_messages(cwd, base_branch, current_branch)
return bool(list(commit_messages))
except Exception:
logger.exception('Failed to check commits on branch %s', current_branch)
return False
def run_cmd(cmd: list[str], cwd: Path | None = None, check=True) -> bool:
"""Run a shell command and return its success status.
Args:
cmd: The command to run as a list of strings.
cwd: The directory to run the command in.
check: Whether to raise an exception if the command fails.
Returns:
True if the command succeeded, False otherwise.
"""
result = subprocess.run(cmd, check=check, cwd=cwd)
return result.returncode == 0
SKIP_AIDER = False
def solve_issue_in_repository(
repository_config: RepositoryConfig,
tmpdirname: Path,
branch_name: str,
issue_title: str,
issue_description: str,
issue_number: str,
gitea_client,
) -> IssueResolution:
logger.info('### %s #####', issue_title)
# Setup repository
run_cmd(['git', 'clone', repository_config.repo_url(), tmpdirname])
run_cmd(['bash', '-c', AIDER_TEST], tmpdirname)
run_cmd(['git', 'checkout', repository_config.base_branch], tmpdirname)
run_cmd(['git', 'checkout', '-b', branch_name], tmpdirname)
# Run initial ruff pass before aider
run_cmd(['bash', '-c', RUFF_FORMAT_AND_AUTO_FIX], tmpdirname, check=False)
run_cmd(['git', 'add', '.'], tmpdirname)
run_cmd(['git', 'commit', '-m', 'Initial ruff pass'], tmpdirname, check=False)
# Save the commit hash after ruff but before aider
result = subprocess.run(
['git', 'rev-parse', 'HEAD'],
check=True,
cwd=tmpdirname,
capture_output=True,
text=True,
)
pre_aider_commit = result.stdout.strip()
# Run aider
issue_content = f'# {issue_title}\n{issue_description}'
if not SKIP_AIDER:
succeeded = run_cmd(
create_aider_command(issue_content),
tmpdirname,
check=False,
)
else:
logger.warning('Skipping aider command (for testing)')
succeeded = True
if not succeeded:
logger.error('Aider invocation failed for issue #%s', issue_number)
return IssueResolution(False)
# Auto-fix standard code quality stuff after aider
run_cmd(['bash', '-c', RUFF_FORMAT_AND_AUTO_FIX], tmpdirname, check=False)
run_cmd(['git', 'add', '.'], tmpdirname)
run_cmd(['git', 'commit', '-m', 'Ruff after aider'], tmpdirname, check=False)
# Check if aider made any changes beyond the initial ruff pass
result = subprocess.run(
['git', 'diff', pre_aider_commit, 'HEAD', '--name-only'],
check=True,
cwd=tmpdirname,
capture_output=True,
text=True,
)
files_changed = result.stdout.strip()
if not files_changed and not SKIP_AIDER:
logger.info(
'Aider did not make any changes beyond the initial ruff pass for issue #%s',
issue_number,
)
return IssueResolution(False)
# Push changes
return push_changes(
repository_config,
tmpdirname,
branch_name,
issue_number,
issue_title,
gitea_client,
)
def solve_issues_in_repository(
repository_config: RepositoryConfig, client, seen_issues_db,
):
"""Process all open issues with the 'aider' label.
Args:
repository_config: Command line arguments.
client: The Gitea client instance.
seen_issues_db: Database of previously processed issues.
"""
try:
issues = client.get_issues(repository_config.owner, repository_config.repo)
except Exception:
logger.exception('Failed to retrieve issues')
sys.exit(1)
if not issues:
logger.info('No issues found for %s', repository_config.repo)
return
for issue in issues:
issue_url = issue.get('web_url')
issue_number = issue.get('number')
issue_description = issue.get('body', '')
title = issue.get('title', f'Issue {issue_number}')
if seen_issues_db.has_seen(issue_url):
logger.info('Skipping already processed issue #%s: %s', issue_number, title)
continue
branch_name = generate_branch_name(issue_number, title)
with tempfile.TemporaryDirectory() as tmpdirname:
issue_resolution = solve_issue_in_repository(
repository_config,
Path(tmpdirname),
branch_name,
title,
issue_description,
issue_number,
client,
)
if issue_resolution.success:
seen_issues_db.mark_as_seen(issue_url, str(issue_number))
seen_issues_db.update_pr_info(
issue_url,
issue_resolution.pull_request_id,
issue_resolution.pull_request_url,
)
logger.info(
'Stored PR #%s information for issue #%s',
issue_resolution.pull_request_id,
issue_number,
)

View File

@ -4,78 +4,158 @@ This script downloads issues from a given Gitea repository and produces a pull r
It assumes that the default branch (default "main") exists and that you have a valid API token if authentication is required. It assumes that the default branch (default "main") exists and that you have a valid API token if authentication is required.
""" """
import argparse
import logging import logging
import time from pathlib import Path
import argparse
import requests
import sys
import dataclasses
import tempfile
import subprocess
import os
from . import RepositoryConfig, secrets, solve_issues_in_repository import secret_loader
from .gitea_client import GiteaClient import re
from .seen_issues_db import SeenIssuesDB
def generate_branch_name(issue_title: str) -> str:
"""
Create a branch name by sanitizing the issue title.
Non-alphanumeric characters (except spaces) are removed,
the text is lowercased, and spaces are replaced with dashes.
"""
sanitized = re.sub(r"[^0-9a-zA-Z ]+", "", issue_title)
return "issue-" + "-".join(sanitized.lower().split())
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
AIDER_TEST="pytest test"
AIDER_LINT="ruff format; ruff check --fix --ignore RUF022 --ignore PGH004; ruff format; ruff check --ignore RUF022 --ignore PGH004;"
MODEL = 'o3-mini'
SECRETS = secret_loader.SecretLoader()
LLM_API_KEY = SECRETS.load_or_fail('LLM_API_KEY')
GITEA_TOKEN = SECRETS.load_or_fail('GITEA_TOKEN')
def create_aider_command(issue: str) -> list[str]:
return [
'aider',
'--model', MODEL,
'--chat-language', 'english',
'--test-cmd', AIDER_TEST,
'--lint-cmd', AIDER_LINT,
'--auto-test',
'--no-auto-lint',
'--api-key', LLM_API_KEY,
'--read', 'CONVENTIONS.md',
'--message', issue,
'--yes-always',
'--architect',
]
class GiteaClient:
def __init__(self, gitea_url: str, token: str) -> None:
assert not gitea_url.endswith('/api/v1')
self.gitea_url = gitea_url + '/api/v1'
self.session = requests.Session()
self.session.headers["Content-Type"] = "application/json"
if token:
self.session.headers["Authorization"] = f"token {token}"
def get_default_branch_sha(self, owner, repo, branch):
"""Retrieve the commit SHA of the default branch."""
url = f"{self.gitea_url}/repos/{owner}/{repo}/branches/{branch}"
response = self.session.get(url)
response.raise_for_status()
data = response.json()
return data['commit']['sha']
def create_branch(self, owner, repo, new_branch, sha):
"""Create a new branch from the provided SHA."""
url = f"{self.gitea_url}/repos/{owner}/{repo}/git/refs"
json_data = {"ref": f"refs/heads/{new_branch}", "sha": sha}
response = self.session.post(url, json=json_data)
if response.status_code == 422:
logger.warning(f"Branch {new_branch} already exists.")
return False
response.raise_for_status()
return True
def get_issues(self, owner, repo):
"""Download issues from the specified repository."""
url = f"{self.gitea_url}/repos/{owner}/{repo}/issues"
response = self.session.get(url)
response.raise_for_status()
return response.json()
def create_pull_request(self, owner, repo, title, head, base, body):
"""Create a pull request for the given branch."""
url = f"{self.gitea_url}/repos/{owner}/{repo}/pulls"
json_data = {"title": title, "head": head, "base": base, "body": body}
response = self.session.post(url, json=json_data)
response.raise_for_status()
return response.json()
def parse_args(): def parse_args():
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(description="Download issues and create pull requests for a Gitea repository.")
description='Download issues and create pull requests for a Gitea repository.', parser.add_argument("--gitea-url", required=True, help="Base URL for the Gitea instance, e.g., https://gitfub.space/api/v1")
) parser.add_argument("--owner", required=True, help="Owner of the repository")
parser.add_argument( parser.add_argument("--repo", required=True, help="Repository name")
'--gitea-url', parser.add_argument("--base-branch", default="main", help="Base branch to use for new branches (default: main)")
required=True,
help='Base URL for the Gitea instance, e.g., https://gitfub.space/api/v1',
)
parser.add_argument('--owner', required=True, help='Owner of the repository')
parser.add_argument(
'--repo',
help='Repository name. If not specified, all repositories for the owner will be scanned.',
)
parser.add_argument(
'--base-branch',
default='main',
help='Base branch to use for new branches (default: main)',
)
parser.add_argument(
'--daemon',
action='store_true',
help='Run in daemon mode to continuously monitor for new issues',
)
parser.add_argument(
'--interval',
type=int,
default=300,
help='Interval in seconds between checks in daemon mode (default: 300)',
)
return parser.parse_args() return parser.parse_args()
def run_cmd(cmd: list[str], cwd:Path|None=None) -> None:
print(cmd)
subprocess.run(cmd, check=True, cwd=cwd)
def process_issue(args, tmpdirname: Path, branch_name: str, issue_title: str, issue_description: str, issue_number: str):
repo_url = f"{args.gitea_url}:{args.owner}/{args.repo}.git".replace('https://', 'git@')
run_cmd(["git", "clone", repo_url, tmpdirname])
run_cmd(["git", "checkout", args.base_branch], tmpdirname)
run_cmd(["git", "checkout", "-b", branch_name], tmpdirname)
run_cmd(create_aider_command(f'# {issue_title}\n{issue_description}'), tmpdirname)
run_cmd(["git", "add", "."], tmpdirname)
run_cmd(["git", "push", "origin", branch_name], tmpdirname)
def main(): def main():
logging.basicConfig(level='INFO') logging.basicConfig(level='INFO')
args = parse_args() args = parse_args()
seen_issues_db = SeenIssuesDB() client = GiteaClient(args.gitea_url, GITEA_TOKEN )
client = GiteaClient(args.gitea_url, secrets.gitea_token())
if args.repo: try:
repositories = [args.repo] issues = client.get_issues(args.owner, args.repo)
else: except Exception:
repositories = list(client.iter_user_repositories(args.owner, True)) logger.exception('Failed to retrieve issues')
sys.exit(1)
while True: if not issues:
logger.info('Checking for new issues...') logger.info("No issues found.")
for repo in repositories: return
repository_config = RepositoryConfig(
gitea_url=args.gitea_url,
owner=args.owner,
repo=repo,
base_branch=args.base_branch,
)
solve_issues_in_repository(repository_config, client, seen_issues_db)
del repo
if not args.daemon:
break
logger.info('Sleeping for %d seconds...', args.interval)
time.sleep(args.interval)
for issue in issues:
issue_number = issue.get("number")
issue_description = issue.get("body", "")
title = issue.get("title", f"Issue {issue_number}")
branch_name = generate_branch_name(title)
try:
with tempfile.TemporaryDirectory() as tmpdirname:
process_issue(args, Path(tmpdirname), branch_name, title, issue_description, issue_number)
logger.info(f"Created branch {branch_name} for issue {issue_number}.")
except Exception:
logger.exception('Error processing issue')
sys.exit(1)
if __name__ == '__main__': body = f"Automatically generated pull request for issue: {issue.get('html_url', 'unknown')}"
try:
pr = client.create_pull_request(args.owner, args.repo, f"[Issue {issue_number}] {title}", branch_name, args.base_branch, body)
logger.info(f"Created pull request: {pr.get('html_url', 'unknown')} for issue {issue_number}.")
except Exception:
logger.exception('"Error creating pull request for branch')
sys.exit(1)
if __name__ == "__main__":
main() main()

View File

@ -1 +1 @@
__version__ = '0.1.6' __version__ = '0.1.0'

View File

@ -1,170 +0,0 @@
import logging
from collections.abc import Iterator
import requests
logger = logging.getLogger(__name__)
class GiteaClient:
"""Client for interacting with the Gitea API.
This class provides methods to interact with a Gitea instance's API,
including retrieving repository information, creating branches, and fetching issues.
Read more about the Gitea API here: https://gitea.com/api/swagger
Attributes:
gitea_url (str): The base URL for the Gitea API endpoints.
session (requests.Session): HTTP session for making API requests.
"""
def __init__(self, gitea_url: str, token: str) -> None:
"""Initialize a new Gitea API client.
Args:
gitea_url (str): Base URL for the Gitea instance (without '/api/v1').
token (str): Authentication token for the Gitea API. If empty, requests will be unauthenticated.
Raises:
AssertionError: If gitea_url ends with '/api/v1'.
"""
assert not gitea_url.endswith('/api/v1')
self.gitea_url = gitea_url + '/api/v1'
self.session = requests.Session()
self.session.headers['Content-Type'] = 'application/json'
if token:
self.session.headers['Authorization'] = f'token {token}'
def get_default_branch_sha(self, owner: str, repo: str, branch: str) -> str:
"""Retrieve the commit SHA of the specified branch.
Args:
owner (str): Owner of the repository.
repo (str): Name of the repository.
branch (str): Name of the branch.
Returns:
str: The commit SHA of the specified branch.
Raises:
requests.HTTPError: If the API request fails.
"""
url = f'{self.gitea_url}/repos/{owner}/{repo}/branches/{branch}'
response = self.session.get(url)
response.raise_for_status()
data = response.json()
return data['commit']['sha']
def create_branch(self, owner: str, repo: str, new_branch: str, sha: str) -> bool:
"""Create a new branch from the provided SHA.
Args:
owner (str): Owner of the repository.
repo (str): Name of the repository.
new_branch (str): Name of the new branch to create.
sha (str): Commit SHA to use as the starting point for the new branch.
Returns:
bool: True if the branch was created successfully, False if the branch already exists.
Raises:
requests.HTTPError: If the API request fails for reasons other than branch already existing.
"""
url = f'{self.gitea_url}/repos/{owner}/{repo}/git/refs'
json_data = {'ref': f'refs/heads/{new_branch}', 'sha': sha}
response = self.session.post(url, json=json_data)
if response.status_code == 422:
logger.warning('Branch %s already exists.', new_branch)
return False
response.raise_for_status()
return True
def get_issues(self, owner: str, repo: str) -> list[dict[str, str]]:
"""Download issues from the specified repository and filter those with the 'aider' label.
Args:
owner (str): Owner of the repository.
repo (str): Name of the repository.
Returns:
list: A list of issue dictionaries, filtered to only include issues with the 'aider' label.
Raises:
requests.HTTPError: If the API request fails.
"""
url = f'{self.gitea_url}/repos/{owner}/{repo}/issues'
response = self.session.get(url)
response.raise_for_status()
issues = response.json()
# Filter to only include issues marked with the "aider" label.
issues = [
issue
for issue in issues
if any(label.get('name') == 'aider' for label in issue.get('labels', []))
]
return issues
def iter_user_repositories(
self,
owner: str,
only_those_with_issues: bool = False,
) -> Iterator[str]:
"""Get a list of repositories for a given user.
Args:
owner (str): The owner of the repositories.
only_those_with_issues (bool): If True, only return repositories with issues enabled.
Returns:
Iterator[str]: An iterator of repository names.
"""
url = f'{self.gitea_url}/user/repos'
response = self.session.get(url)
response.raise_for_status()
for repo in response.json():
if only_those_with_issues and not repo['has_issues']:
continue
if repo['owner']['login'].lower() != owner.lower():
continue
yield repo['name']
def create_pull_request(
self,
owner: str,
repo: str,
title: str,
body: str,
head: str,
base: str,
labels: list[str] = None,
) -> dict:
"""Create a pull request and optionally apply labels.
Args:
owner (str): Owner of the repository.
repo (str): Name of the repository.
title (str): Title of the pull request.
body (str): Description/body of the pull request.
head (str): The name of the branch where changes are implemented.
base (str): The name of the branch you want the changes pulled into.
labels (list[str], optional): List of label names to apply to the pull request.
Returns:
dict: The created pull request data.
Raises:
requests.HTTPError: If the API request fails.
"""
url = f'{self.gitea_url}/repos/{owner}/{repo}/pulls'
json_data = {
'title': title,
'body': body,
'head': head,
'base': base,
}
response = self.session.post(url, json=json_data)
response.raise_for_status()
return response.json()

View File

@ -1,11 +0,0 @@
import secret_loader
SECRETS = secret_loader.SecretLoader()
def llm_api_keys() -> list[str]:
return SECRETS.load_or_fail('LLM_API_KEY').strip().split('\n')
def gitea_token() -> str:
return SECRETS.load_or_fail('GITEA_TOKEN')

View File

@ -1,123 +0,0 @@
"""Database module for tracking previously processed issues and pull requests.
This module provides functionality to track which issues have already been processed
by the system to avoid duplicate processing. It uses a simple SQLite database to
store information about seen issues and their associated pull requests for efficient lookup.
"""
import sqlite3
DEFAULT_DB_PATH = 'output/seen_issues.db'
class SeenIssuesDB:
"""Database handler for tracking processed issues and pull requests.
This class manages a SQLite database that stores information about issues that have
already been processed and their associated pull requests. It provides methods to mark
issues as seen, check if an issue has been seen before, and retrieve pull request
information for an issue.
Attributes:
conn: SQLite database connection
"""
def __init__(self, db_path=DEFAULT_DB_PATH):
"""Initialize the database connection.
Args:
db_path: Path to the SQLite database file. Defaults to 'output/seen_issues.db'.
"""
self.conn = sqlite3.connect(db_path)
self._create_table()
def _create_table(self):
"""Create the seen_issues table if it doesn't exist.
Creates a table with columns for storing issue hashes and associated pull request information.
"""
with self.conn:
self.conn.execute("""
CREATE TABLE IF NOT EXISTS seen_issues (
issue_url TEXT PRIMARY KEY,
issue_number TEXT,
pr_number TEXT,
pr_url TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
def mark_as_seen(
self,
issue_url: str,
issue_number: str | None = None,
pr_number: str | None = None,
pr_url: str | None = None,
):
"""Mark an issue as seen in the database.
Computes a hash of the issue text and stores it in the database along with pull request information.
If the issue has already been marked as seen, this operation has no effect.
Args:
issue_url: The text content of the issue to mark as seen.
issue_number: The issue number.
pr_number: The pull request number associated with this issue.
pr_url: The URL of the pull request associated with this issue.
"""
with self.conn:
self.conn.execute(
'INSERT OR IGNORE INTO seen_issues (issue_url, issue_number, pr_number, pr_url) VALUES (?, ?, ?, ?)',
(issue_url, issue_number, pr_number, pr_url),
)
def has_seen(self, issue_url: str) -> bool:
"""Check if an issue has been seen before.
Computes a hash of the issue text and checks if it exists in the database.
Args:
issue_url: The text content of the issue to check.
Returns:
True if the issue has been seen before, False otherwise.
"""
cursor = self.conn.execute(
'SELECT 1 FROM seen_issues WHERE issue_url = ?',
(issue_url,),
)
return cursor.fetchone() is not None
def get_pr_info(self, issue_url: str) -> tuple[str, str] | None:
"""Get pull request information for an issue.
Args:
issue_url: The text content of the issue to check.
Returns:
A tuple containing (pr_number, pr_url) if found, None otherwise.
"""
cursor = self.conn.execute(
'SELECT pr_number, pr_url FROM seen_issues WHERE issue_url = ?',
(issue_url,),
)
result = cursor.fetchone()
return result if result else None
def update_pr_info(self, issue_url: str, pr_number: str, pr_url: str) -> bool:
"""Update pull request information for an existing issue.
Args:
issue_url: The text content of the issue to update.
pr_number: The pull request number.
pr_url: The URL of the pull request.
Returns:
True if the update was successful, False if the issue wasn't found.
"""
with self.conn:
cursor = self.conn.execute(
'UPDATE seen_issues SET pr_number = ?, pr_url = ? WHERE issue_url = ?',
(pr_number, pr_url, issue_url),
)
return cursor.rowcount > 0

View File

@ -1 +0,0 @@
secret_loader @ git+https://gitfub.space/Jmaa/secret_loader.git

View File

@ -11,77 +11,10 @@ from setuptools import setup
PACKAGE_NAME = 'aider_gitea' PACKAGE_NAME = 'aider_gitea'
PACKAGE_DESCRIPTION = """ PACKAGE_DESCRIPTION = """
Aider Gitea.
A code automation tool that integrates Gitea with Aider to automatically solve issues.
This program monitors your [Gitea](https://about.gitea.com/) repository for issues with the 'aider' label.
When such an issue is found, it:
1. Creates a new branch.
2. Invokes [Aider](https://aider.chat/) to solve the issue using a Large-Language Model.
3. Runs tests and code quality checks.
4. Creates a pull request with the solution.
## Usage
An application token must be supplied for the `gitea_token` secret. This must
have the following permissions:
- `read:issue`: To be able to read issues on the specified repository.
- `write:repository`: To be able to create pull requests.
- `read:user`: Needed to iterate all user's repositories.
### Command Line
```bash
# Run with default settings
python -m aider_gitea
# Specify custom repository and owner
python -m aider_gitea --owner myorg --repo myproject
# Use a custom Gitea URL
python -m aider_gitea --gitea-url https://gitea.example.com
# Specify a different base branch
python -m aider_gitea --base-branch develop
```
### Python API
```python
from aider_gitea import solve_issue_in_repository
from pathlib import Path
# Solve an issue programmatically
args = argparse.Namespace(
gitea_url="https://gitea.example.com",
owner="myorg",
repo="myproject",
base_branch="main"
)
solve_issue_in_repository(
args,
Path("/path/to/repo"),
"issue-123-fix-bug",
"Fix critical bug",
"The application crashes when processing large files",
"123"
)
```
### Environment Configuration
The tool uses environment variables for sensitive information:
- `GITEA_TOKEN`: Your Gitea API token
- `LLM_API_KEY`: API key for the language model used by Aider
```
""".strip() """.strip()
PACKAGE_DESCRIPTION_SHORT = """ PACKAGE_DESCRIPTION_SHORT = """
A code automation tool that integrates Gitea with Aider to automatically solve issues.""".strip() """.strip()
def parse_version_file(text: str) -> str: def parse_version_file(text: str) -> str:
@ -96,9 +29,7 @@ with open(PACKAGE_NAME + '/_version.py') as f:
version = parse_version_file(f.read()) version = parse_version_file(f.read())
REQUIREMENTS_MAIN = [ REQUIREMENTS_MAIN = []
'secret_loader @ git+https://gitfub.space/Jmaa/secret_loader.git',
]
REQUIREMENTS_TEST = [] REQUIREMENTS_TEST = []

View File

@ -1 +0,0 @@
"""Test package for aider_gitea."""

View File

@ -1,18 +0,0 @@
from aider_gitea import generate_branch_name
def test_generate_branch_name_normal():
# Normal case with alphanumeric title.
branch = generate_branch_name('123', 'Some Issue Title')
assert branch == 'issue-123-some-issue-title'
def test_generate_branch_name_special_characters():
branch = generate_branch_name('45', 'Issue @ Special!')
assert branch == 'issue-45-issue-special'
def test_generate_branch_name_numeric_title():
# Test where the title starts with numbers.
branch = generate_branch_name('789', '123 Numbers Here')
assert branch == 'issue-789-123-numbers-here'

View File

@ -1,50 +0,0 @@
from unittest.mock import MagicMock, patch
from aider_gitea.gitea_client import GiteaClient
class TestGiteaClientPRLabels:
def setup_method(self):
self.client = GiteaClient('https://gitea.example.com', 'fake_token')
@patch('requests.Session.post')
def test_create_pull_request_with_labels(self, mock_post):
# Mock the PR creation response
pr_response = MagicMock()
pr_response.status_code = 201
expected_result = {
'number': 123,
'title': 'Test PR',
'html_url': 'https://gitea.example.com/owner/repo/pulls/123',
}
pr_response.json.return_value = expected_result
# Mock the label addition response
label_response = MagicMock()
label_response.status_code = 200
# Set up the mock to return different responses for different calls
mock_post.side_effect = [pr_response, label_response]
# Call the method with labels
result = self.client.create_pull_request(
owner='owner',
repo='repo',
title='Test PR',
body='Test body',
head='feature-branch',
base='main',
labels=['aider'],
)
# Verify PR creation call
assert mock_post.call_count == 1
pr_call_args = mock_post.call_args_list[0]
assert (
pr_call_args[0][0]
== 'https://gitea.example.com/api/v1/repos/owner/repo/pulls'
)
assert pr_call_args[1]['json']['title'] == 'Test PR'
# Verify the result
assert result == expected_result

View File

@ -1,54 +0,0 @@
from pathlib import Path
from unittest.mock import patch
from aider_gitea import has_commits_on_branch
class TestHasCommitsOnBranch:
def setup_method(self):
self.cwd = Path('/tmp/test-repo')
self.base_branch = 'main'
self.current_branch = 'feature-branch'
@patch('aider_gitea.get_commit_messages')
def test_has_commits_true(self, mock_get_commit_messages):
# Setup mock to return some commit messages
mock_get_commit_messages.return_value = ['Commit 1', 'Commit 2']
# Test function returns True when there are commits
assert (
has_commits_on_branch(self.cwd, self.base_branch, self.current_branch)
is True
)
# Verify get_commit_messages was called with correct arguments
mock_get_commit_messages.assert_called_once_with(
self.cwd, self.base_branch, self.current_branch,
)
@patch('aider_gitea.get_commit_messages')
def test_has_commits_false(self, mock_get_commit_messages):
# Setup mock to return empty list
mock_get_commit_messages.return_value = []
# Test function returns False when there are no commits
assert (
has_commits_on_branch(self.cwd, self.base_branch, self.current_branch)
is False
)
# Verify get_commit_messages was called with correct arguments
mock_get_commit_messages.assert_called_once_with(
self.cwd, self.base_branch, self.current_branch,
)
@patch('aider_gitea.get_commit_messages')
def test_has_commits_exception(self, mock_get_commit_messages):
# Setup mock to raise an exception
mock_get_commit_messages.side_effect = Exception('Git command failed')
# Test function returns False when an exception occurs
assert (
has_commits_on_branch(self.cwd, self.base_branch, self.current_branch)
is False
)

View File

@ -1,3 +1,4 @@
def test_init(): def test_init():
import aider_gitea #noqa: F401 import aider_gitea #noqa: F401
import aider_gitea.secrets # noqa: F401

View File

@ -1,16 +0,0 @@
from aider_gitea.seen_issues_db import SeenIssuesDB
class TestSeenIssuesDB:
def setup_method(self):
self.db = SeenIssuesDB(':memory:')
def test_mark_and_check_seen_issue(self):
issue_text = 'Test issue'
assert not self.db.has_seen(issue_text)
self.db.mark_as_seen(issue_text)
assert self.db.has_seen(issue_text)
def test_unseen_issue(self):
issue_text = 'Unseen issue'
assert not self.db.has_seen(issue_text)

View File

@ -1,77 +0,0 @@
import os
import tempfile
from aider_gitea.seen_issues_db import SeenIssuesDB
class TestSeenIssuesDBPRInfo:
def setup_method(self):
# Create a temporary database file
self.db_fd, self.db_path = tempfile.mkstemp()
self.db = SeenIssuesDB(self.db_path)
# Test data
self.issue_text = 'Test issue title\nTest issue description'
self.issue_number = '123'
self.pr_number = '456'
self.pr_url = 'https://gitea.example.com/owner/repo/pulls/456'
def teardown_method(self):
# Close and remove the temporary database
self.db.conn.close()
os.close(self.db_fd)
os.unlink(self.db_path)
def test_mark_as_seen_with_pr_info(self):
# Mark an issue as seen with PR info
self.db.mark_as_seen(
self.issue_text,
issue_number=self.issue_number,
pr_number=self.pr_number,
pr_url=self.pr_url,
)
# Verify the issue is marked as seen
assert self.db.has_seen(self.issue_text)
# Verify PR info is stored correctly
pr_info = self.db.get_pr_info(self.issue_text)
assert pr_info is not None
assert pr_info[0] == self.pr_number
assert pr_info[1] == self.pr_url
def test_update_pr_info(self):
# First mark the issue as seen without PR info
self.db.mark_as_seen(self.issue_text, issue_number=self.issue_number)
# Verify no PR info is available
assert self.db.get_pr_info(self.issue_text) == (None, None)
# Update with PR info
updated = self.db.update_pr_info(self.issue_text, self.pr_number, self.pr_url)
# Verify update was successful
assert updated
# Verify PR info is now available
pr_info = self.db.get_pr_info(self.issue_text)
assert pr_info[0] == self.pr_number
assert pr_info[1] == self.pr_url
def test_update_nonexistent_issue(self):
# Try to update PR info for an issue that doesn't exist
updated = self.db.update_pr_info(
'Nonexistent issue',
self.pr_number,
self.pr_url,
)
# Verify update failed
assert not updated
def test_get_pr_info_nonexistent(self):
# Try to get PR info for an issue that doesn't exist
pr_info = self.db.get_pr_info('Nonexistent issue')
# Verify no PR info is available
assert pr_info is None

View File

@ -1,101 +0,0 @@
from pathlib import Path
from unittest.mock import MagicMock, patch
from aider_gitea import IssueResolution, RepositoryConfig, solve_issue_in_repository
REPOSITORY_CONFIG = RepositoryConfig(
gitea_url='https://gitea.example.com',
owner='test-owner',
repo='test-repo',
base_branch='main',
)
class TestSolveIssueInRepository:
def setup_method(self):
self.gitea_client = MagicMock()
self.tmpdirname = Path('/tmp/test-repo')
self.branch_name = 'issue-123-test-branch'
self.issue_title = 'Test Issue'
self.issue_description = 'This is a test issue'
self.issue_number = '123'
@patch('aider_gitea.secrets.llm_api_keys', return_value='fake-api-key')
@patch('aider_gitea.run_cmd')
@patch('aider_gitea.push_changes')
@patch('subprocess.run')
def test_solve_issue_with_aider_changes(
self,
mock_subprocess_run,
mock_push_changes,
mock_run_cmd,
mock_llm_api_key,
):
# Setup mocks
mock_run_cmd.return_value = True
mock_push_changes.return_value = IssueResolution(
True,
'456',
'https://gitea.example.com/test-owner/test-repo/pulls/456',
)
# Mock subprocess.run to return different commit hashes and file changes
mock_subprocess_run.side_effect = [
MagicMock(stdout='abc123\n', returncode=0), # First git rev-parse
MagicMock(
stdout='file1.py\nfile2.py\n',
returncode=0,
), # git diff with changes
]
# Call the function
result = solve_issue_in_repository(
REPOSITORY_CONFIG,
self.tmpdirname,
self.branch_name,
self.issue_title,
self.issue_description,
self.issue_number,
self.gitea_client,
)
# Verify results
assert result.success is True
assert mock_run_cmd.call_count >= 8 # Verify all expected commands were run
mock_push_changes.assert_called_once()
@patch('aider_gitea.secrets.llm_api_keys', return_value='fake-api-key')
@patch('aider_gitea.run_cmd')
@patch('aider_gitea.push_changes')
@patch('subprocess.run')
def test_solve_issue_without_aider_changes(
self,
mock_subprocess_run,
mock_push_changes,
mock_run_cmd,
mock_llm_api_key,
):
# Setup mocks
mock_run_cmd.return_value = True
mock_push_changes.return_value = IssueResolution(False, None, None)
# Mock subprocess.run to return same commit hash and no file changes
mock_subprocess_run.side_effect = [
MagicMock(stdout='abc123\n', returncode=0), # First git rev-parse
MagicMock(stdout='', returncode=0), # git diff with no changes
]
# Call the function
result = solve_issue_in_repository(
REPOSITORY_CONFIG,
self.tmpdirname,
self.branch_name,
self.issue_title,
self.issue_description,
self.issue_number,
self.gitea_client,
)
# Verify results
assert result.success is False
assert mock_push_changes.call_count == 0 # push_changes should not be called