diff --git a/README.md b/README.md index 21c2309a..8fcfb0d0 100644 --- a/README.md +++ b/README.md @@ -70,47 +70,47 @@ Read more about it [here](https://qodo-merge-docs.qodo.ai/tools/scan_repo_discus Supported commands per platform: -| | | GitHub | GitLab | Bitbucket | Azure DevOps | -| ----- |---------------------------------------------------------------------------------------------------------|:------:|:------:|:---------:|:------------:| -| TOOLS | [Review](https://qodo-merge-docs.qodo.ai/tools/review/) | ✅ | ✅ | ✅ | ✅ | -| | [Describe](https://qodo-merge-docs.qodo.ai/tools/describe/) | ✅ | ✅ | ✅ | ✅ | -| | [Improve](https://qodo-merge-docs.qodo.ai/tools/improve/) | ✅ | ✅ | ✅ | ✅ | -| | [Ask](https://qodo-merge-docs.qodo.ai/tools/ask/) | ✅ | ✅ | ✅ | ✅ | -| | ⮑ [Ask on code lines](https://qodo-merge-docs.qodo.ai/tools/ask/#ask-lines) | ✅ | ✅ | | | -| | [Update CHANGELOG](https://qodo-merge-docs.qodo.ai/tools/update_changelog/) | ✅ | ✅ | ✅ | ✅ | -| | [Help Docs](https://qodo-merge-docs.qodo.ai/tools/help_docs/?h=auto#auto-approval) | ✅ | ✅ | ✅ | | -| | [Ticket Context](https://qodo-merge-docs.qodo.ai/core-abilities/fetching_ticket_context/) 💎 | ✅ | ✅ | ✅ | | -| | [Utilizing Best Practices](https://qodo-merge-docs.qodo.ai/tools/improve/#best-practices) 💎 | ✅ | ✅ | ✅ | | -| | [PR Chat](https://qodo-merge-docs.qodo.ai/chrome-extension/features/#pr-chat) 💎 | ✅ | | | | -| | [Suggestion Tracking](https://qodo-merge-docs.qodo.ai/tools/improve/#suggestion-tracking) 💎 | ✅ | ✅ | | | -| | [CI Feedback](https://qodo-merge-docs.qodo.ai/tools/ci_feedback/) 💎 | ✅ | | | | -| | [PR Documentation](https://qodo-merge-docs.qodo.ai/tools/documentation/) 💎 | ✅ | ✅ | | | -| | [Custom Labels](https://qodo-merge-docs.qodo.ai/tools/custom_labels/) 💎 | ✅ | ✅ | | | -| | [Analyze](https://qodo-merge-docs.qodo.ai/tools/analyze/) 💎 | ✅ | ✅ | | | -| | [Similar Code](https://qodo-merge-docs.qodo.ai/tools/similar_code/) 💎 | ✅ | | | | -| | [Custom Prompt](https://qodo-merge-docs.qodo.ai/tools/custom_prompt/) 💎 | ✅ | ✅ | ✅ | | -| | [Test](https://qodo-merge-docs.qodo.ai/tools/test/) 💎 | ✅ | ✅ | | | -| | [Implement](https://qodo-merge-docs.qodo.ai/tools/implement/) 💎 | ✅ | ✅ | ✅ | | -| | [Scan Repo Discussions](https://qodo-merge-docs.qodo.ai/tools/scan_repo_discussions/) 💎 | ✅ | | | | -| | [Auto-Approve](https://qodo-merge-docs.qodo.ai/tools/improve/?h=auto#auto-approval) 💎 | ✅ | ✅ | ✅ | | -| | | | | | | -| USAGE | [CLI](https://qodo-merge-docs.qodo.ai/usage-guide/automations_and_usage/#local-repo-cli) | ✅ | ✅ | ✅ | ✅ | -| | [App / webhook](https://qodo-merge-docs.qodo.ai/usage-guide/automations_and_usage/#github-app) | ✅ | ✅ | ✅ | ✅ | -| | [Tagging bot](https://github.com/Codium-ai/pr-agent#try-it-now) | ✅ | | | | -| | [Actions](https://qodo-merge-docs.qodo.ai/installation/github/#run-as-a-github-action) | ✅ | ✅ | ✅ | ✅ | -| | | | | | | -| CORE | [PR compression](https://qodo-merge-docs.qodo.ai/core-abilities/compression_strategy/) | ✅ | ✅ | ✅ | ✅ | -| | Adaptive and token-aware file patch fitting | ✅ | ✅ | ✅ | ✅ | -| | [Multiple models support](https://qodo-merge-docs.qodo.ai/usage-guide/changing_a_model/) | ✅ | ✅ | ✅ | ✅ | -| | [Local and global metadata](https://qodo-merge-docs.qodo.ai/core-abilities/metadata/) | ✅ | ✅ | ✅ | ✅ | -| | [Dynamic context](https://qodo-merge-docs.qodo.ai/core-abilities/dynamic_context/) | ✅ | ✅ | ✅ | ✅ | -| | [Self reflection](https://qodo-merge-docs.qodo.ai/core-abilities/self_reflection/) | ✅ | ✅ | ✅ | ✅ | -| | [Static code analysis](https://qodo-merge-docs.qodo.ai/core-abilities/static_code_analysis/) 💎 | ✅ | ✅ | | | -| | [Global and wiki configurations](https://qodo-merge-docs.qodo.ai/usage-guide/configuration_options/) 💎 | ✅ | ✅ | ✅ | | -| | [PR interactive actions](https://www.qodo.ai/images/pr_agent/pr-actions.mp4) 💎 | ✅ | ✅ | | | -| | [Impact Evaluation](https://qodo-merge-docs.qodo.ai/core-abilities/impact_evaluation/) 💎 | ✅ | ✅ | | | -| | [Code Validation 💎](https://qodo-merge-docs.qodo.ai/core-abilities/code_validation/) | ✅ | ✅ | ✅ | ✅ | -| | [Auto Best Practices 💎](https://qodo-merge-docs.qodo.ai/core-abilities/auto_best_practices/) | ✅ | | | | +| | | GitHub | GitLab | Bitbucket | Azure DevOps | Gitea | +| ----- |---------------------------------------------------------------------------------------------------------|:------:|:------:|:---------:|:------------:|:-----:| +| TOOLS | [Review](https://qodo-merge-docs.qodo.ai/tools/review/) | ✅ | ✅ | ✅ | ✅ | ✅ | +| | [Describe](https://qodo-merge-docs.qodo.ai/tools/describe/) | ✅ | ✅ | ✅ | ✅ | ✅ | +| | [Improve](https://qodo-merge-docs.qodo.ai/tools/improve/) | ✅ | ✅ | ✅ | ✅ | ✅ | +| | [Ask](https://qodo-merge-docs.qodo.ai/tools/ask/) | ✅ | ✅ | ✅ | ✅ | | +| | ⮑ [Ask on code lines](https://qodo-merge-docs.qodo.ai/tools/ask/#ask-lines) | ✅ | ✅ | | | | +| | [Update CHANGELOG](https://qodo-merge-docs.qodo.ai/tools/update_changelog/) | ✅ | ✅ | ✅ | ✅ | | +| | [Help Docs](https://qodo-merge-docs.qodo.ai/tools/help_docs/?h=auto#auto-approval) | ✅ | ✅ | ✅ | | | +| | [Ticket Context](https://qodo-merge-docs.qodo.ai/core-abilities/fetching_ticket_context/) 💎 | ✅ | ✅ | ✅ | | | +| | [Utilizing Best Practices](https://qodo-merge-docs.qodo.ai/tools/improve/#best-practices) 💎 | ✅ | ✅ | ✅ | | | +| | [PR Chat](https://qodo-merge-docs.qodo.ai/chrome-extension/features/#pr-chat) 💎 | ✅ | | | | | +| | [Suggestion Tracking](https://qodo-merge-docs.qodo.ai/tools/improve/#suggestion-tracking) 💎 | ✅ | ✅ | | | | +| | [CI Feedback](https://qodo-merge-docs.qodo.ai/tools/ci_feedback/) 💎 | ✅ | | | | | +| | [PR Documentation](https://qodo-merge-docs.qodo.ai/tools/documentation/) 💎 | ✅ | ✅ | | | | +| | [Custom Labels](https://qodo-merge-docs.qodo.ai/tools/custom_labels/) 💎 | ✅ | ✅ | | | | +| | [Analyze](https://qodo-merge-docs.qodo.ai/tools/analyze/) 💎 | ✅ | ✅ | | | | +| | [Similar Code](https://qodo-merge-docs.qodo.ai/tools/similar_code/) 💎 | ✅ | | | | | +| | [Custom Prompt](https://qodo-merge-docs.qodo.ai/tools/custom_prompt/) 💎 | ✅ | ✅ | ✅ | | | +| | [Test](https://qodo-merge-docs.qodo.ai/tools/test/) 💎 | ✅ | ✅ | | | | +| | [Implement](https://qodo-merge-docs.qodo.ai/tools/implement/) 💎 | ✅ | ✅ | ✅ | | | +| | [Scan Repo Discussions](https://qodo-merge-docs.qodo.ai/tools/scan_repo_discussions/) 💎 | ✅ | | | | | +| | [Auto-Approve](https://qodo-merge-docs.qodo.ai/tools/improve/?h=auto#auto-approval) 💎 | ✅ | ✅ | ✅ | | | +| | | | | | | | +| USAGE | [CLI](https://qodo-merge-docs.qodo.ai/usage-guide/automations_and_usage/#local-repo-cli) | ✅ | ✅ | ✅ | ✅ | ✅ | +| | [App / webhook](https://qodo-merge-docs.qodo.ai/usage-guide/automations_and_usage/#github-app) | ✅ | ✅ | ✅ | ✅ | ✅ | +| | [Tagging bot](https://github.com/Codium-ai/pr-agent#try-it-now) | ✅ | | | | | +| | [Actions](https://qodo-merge-docs.qodo.ai/installation/github/#run-as-a-github-action) | ✅ | ✅ | ✅ | ✅ | | +| | | | | | | | +| CORE | [PR compression](https://qodo-merge-docs.qodo.ai/core-abilities/compression_strategy/) | ✅ | ✅ | ✅ | ✅ | | +| | Adaptive and token-aware file patch fitting | ✅ | ✅ | ✅ | ✅ | | +| | [Multiple models support](https://qodo-merge-docs.qodo.ai/usage-guide/changing_a_model/) | ✅ | ✅ | ✅ | ✅ | | +| | [Local and global metadata](https://qodo-merge-docs.qodo.ai/core-abilities/metadata/) | ✅ | ✅ | ✅ | ✅ | | +| | [Dynamic context](https://qodo-merge-docs.qodo.ai/core-abilities/dynamic_context/) | ✅ | ✅ | ✅ | ✅ | | +| | [Self reflection](https://qodo-merge-docs.qodo.ai/core-abilities/self_reflection/) | ✅ | ✅ | ✅ | ✅ | | +| | [Static code analysis](https://qodo-merge-docs.qodo.ai/core-abilities/static_code_analysis/) 💎 | ✅ | ✅ | | | | +| | [Global and wiki configurations](https://qodo-merge-docs.qodo.ai/usage-guide/configuration_options/) 💎 | ✅ | ✅ | ✅ | | | +| | [PR interactive actions](https://www.qodo.ai/images/pr_agent/pr-actions.mp4) 💎 | ✅ | ✅ | | | | +| | [Impact Evaluation](https://qodo-merge-docs.qodo.ai/core-abilities/impact_evaluation/) 💎 | ✅ | ✅ | | | | +| | [Code Validation 💎](https://qodo-merge-docs.qodo.ai/core-abilities/code_validation/) | ✅ | ✅ | ✅ | ✅ | | +| | [Auto Best Practices 💎](https://qodo-merge-docs.qodo.ai/core-abilities/auto_best_practices/) | ✅ | | | | | - 💎 means this feature is available only in [Qodo Merge](https://www.qodo.ai/pricing/) [//]: # (- Support for additional git providers is described in [here](./docs/Full_environments.md)) diff --git a/docker/Dockerfile b/docker/Dockerfile index 9e83e37b..ce609e48 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -33,6 +33,11 @@ FROM base AS azure_devops_webhook ADD pr_agent pr_agent CMD ["python", "pr_agent/servers/azuredevops_server_webhook.py"] +FROM base AS gitea_app +ADD pr_agent pr_agent +CMD ["python", "-m", "gunicorn", "-k", "uvicorn.workers.UvicornWorker", "-c", "pr_agent/servers/gunicorn_config.py","pr_agent.servers.gitea_app:app"] + + FROM base AS test ADD requirements-dev.txt . RUN pip install --no-cache-dir -r requirements-dev.txt && rm requirements-dev.txt diff --git a/docs/docs/installation/gitea.md b/docs/docs/installation/gitea.md new file mode 100644 index 00000000..476497f7 --- /dev/null +++ b/docs/docs/installation/gitea.md @@ -0,0 +1,46 @@ +## Run a Gitea webhook server + +1. In Gitea create a new user and give it "Reporter" role ("Developer" if using Pro version of the agent) for the intended group or project. + +2. For the user from step 1. generate a `personal_access_token` with `api` access. + +3. Generate a random secret for your app, and save it for later (`webhook_secret`). For example, you can use: + +```bash +WEBHOOK_SECRET=$(python -c "import secrets; print(secrets.token_hex(10))") +``` + +4. Clone this repository: + +```bash +git clone https://github.com/qodo-ai/pr-agent.git +``` + +5. Prepare variables and secrets. Skip this step if you plan on setting these as environment variables when running the agent: +1. In the configuration file/variables: + - Set `config.git_provider` to "gitea" + +2. In the secrets file/variables: + - Set your AI model key in the respective section + - In the [Gitea] section, set `personal_access_token` (with token from step 2) and `webhook_secret` (with secret from step 3) + +6. Build a Docker image for the app and optionally push it to a Docker repository. We'll use Dockerhub as an example: + +```bash +docker build -f /docker/Dockerfile -t pr-agent:gitea_app --target gitea_app . +docker push codiumai/pr-agent:gitea_webhook # Push to your Docker repository +``` + +7. Set the environmental variables, the method depends on your docker runtime. Skip this step if you included your secrets/configuration directly in the Docker image. + +```bash +CONFIG__GIT_PROVIDER=gitea +GITEA__PERSONAL_ACCESS_TOKEN= +GITEA__WEBHOOK_SECRET= +GITEA__URL=https://gitea.com # Or self host +OPENAI__KEY= +``` + +8. Create a webhook in your Gitea project. Set the URL to `http[s]:///api/v1/gitea_webhooks`, the secret token to the generated secret from step 3, and enable the triggers `push`, `comments` and `merge request events`. + +9. Test your installation by opening a merge request or commenting on a merge request using one of PR Agent's commands. diff --git a/docs/docs/installation/index.md b/docs/docs/installation/index.md index 9831078d..cc593deb 100644 --- a/docs/docs/installation/index.md +++ b/docs/docs/installation/index.md @@ -9,6 +9,7 @@ There are several ways to use self-hosted PR-Agent: - [GitLab integration](./gitlab.md) - [BitBucket integration](./bitbucket.md) - [Azure DevOps integration](./azure.md) +- [Gitea integration](./gitea.md) ## Qodo Merge 💎 diff --git a/docs/docs/installation/locally.md b/docs/docs/installation/locally.md index cd981f96..9ceb077b 100644 --- a/docs/docs/installation/locally.md +++ b/docs/docs/installation/locally.md @@ -1,7 +1,7 @@ To run PR-Agent locally, you first need to acquire two keys: 1. An OpenAI key from [here](https://platform.openai.com/api-keys){:target="_blank"}, with access to GPT-4 and o4-mini (or a key for other [language models](https://qodo-merge-docs.qodo.ai/usage-guide/changing_a_model/), if you prefer). -2. A personal access token from your Git platform (GitHub, GitLab, BitBucket) with repo scope. GitHub token, for example, can be issued from [here](https://github.com/settings/tokens){:target="_blank"} +2. A personal access token from your Git platform (GitHub, GitLab, BitBucket,Gitea) with repo scope. GitHub token, for example, can be issued from [here](https://github.com/settings/tokens){:target="_blank"} ## Using Docker image @@ -40,6 +40,19 @@ To invoke a tool (for example `review`), you can run PR-Agent directly from the docker run --rm -it -e CONFIG.GIT_PROVIDER=bitbucket -e OPENAI.KEY=$OPENAI_API_KEY -e BITBUCKET.BEARER_TOKEN=$BITBUCKET_BEARER_TOKEN codiumai/pr-agent:latest --pr_url= review ``` +- For Gitea: + + ```bash + docker run --rm -it -e OPENAI.KEY= -e CONFIG.GIT_PROVIDER=gitea -e GITEA.PERSONAL_ACCESS_TOKEN= codiumai/pr-agent:latest --pr_url review + ``` + + If you have a dedicated Gitea instance, you need to specify the custom url as variable: + + ```bash + -e GITEA.URL= + ``` + + For other git providers, update `CONFIG.GIT_PROVIDER` accordingly and check the [`pr_agent/settings/.secrets_template.toml`](https://github.com/Codium-ai/pr-agent/blob/main/pr_agent/settings/.secrets_template.toml) file for environment variables expected names and values. ### Utilizing environment variables diff --git a/docs/docs/usage-guide/automations_and_usage.md b/docs/docs/usage-guide/automations_and_usage.md index 9c3e29fd..0a634e77 100644 --- a/docs/docs/usage-guide/automations_and_usage.md +++ b/docs/docs/usage-guide/automations_and_usage.md @@ -30,7 +30,7 @@ verbosity_level=2 This is useful for debugging or experimenting with different tools. 3. **git provider**: The [git_provider](https://github.com/Codium-ai/pr-agent/blob/main/pr_agent/settings/configuration.toml#L5) field in a configuration file determines the GIT provider that will be used by Qodo Merge. Currently, the following providers are supported: -`github` **(default)**, `gitlab`, `bitbucket`, `azure`, `codecommit`, `local`, and `gerrit`. +`github` **(default)**, `gitlab`, `bitbucket`, `azure`, `codecommit`, `local`,`gitea`, and `gerrit`. ### CLI Health Check @@ -312,3 +312,16 @@ pr_commands = [ "/improve", ] ``` + +### Gitea Webhook + +After setting up a Gitea webhook, to control which commands will run automatically when a new MR is opened, you can set the `pr_commands` parameter in the configuration file, similar to the GitHub App: + +```toml +[gitea] +pr_commands = [ + "/describe", + "/review", + "/improve", +] +``` diff --git a/docs/docs/usage-guide/index.md b/docs/docs/usage-guide/index.md index dba5a569..79df0be6 100644 --- a/docs/docs/usage-guide/index.md +++ b/docs/docs/usage-guide/index.md @@ -12,6 +12,7 @@ It includes information on how to adjust Qodo Merge configurations, define which - [GitHub App](./automations_and_usage.md#github-app) - [GitHub Action](./automations_and_usage.md#github-action) - [GitLab Webhook](./automations_and_usage.md#gitlab-webhook) + - [Gitea Webhook](./automations_and_usage.md#gitea-webhook) - [BitBucket App](./automations_and_usage.md#bitbucket-app) - [Azure DevOps Provider](./automations_and_usage.md#azure-devops-provider) - [Managing Mail Notifications](./mail_notifications.md) diff --git a/pr_agent/algo/file_filter.py b/pr_agent/algo/file_filter.py index 5c575eef..79bb4d8e 100644 --- a/pr_agent/algo/file_filter.py +++ b/pr_agent/algo/file_filter.py @@ -58,6 +58,9 @@ def filter_ignored(files, platform = 'github'): files = files_o elif platform == 'azure': files = [f for f in files if not r.match(f)] + elif platform == 'gitea': + files = [f for f in files if not r.match(f.get("filename", ""))] + except Exception as e: print(f"Could not filter file list: {e}") diff --git a/pr_agent/git_providers/__init__.py b/pr_agent/git_providers/__init__.py index 51c6f624..055cdbf1 100644 --- a/pr_agent/git_providers/__init__.py +++ b/pr_agent/git_providers/__init__.py @@ -8,6 +8,7 @@ from pr_agent.git_providers.bitbucket_server_provider import \ from pr_agent.git_providers.codecommit_provider import CodeCommitProvider from pr_agent.git_providers.gerrit_provider import GerritProvider from pr_agent.git_providers.git_provider import GitProvider +from pr_agent.git_providers.gitea_provider import GiteaProvider from pr_agent.git_providers.github_provider import GithubProvider from pr_agent.git_providers.gitlab_provider import GitLabProvider from pr_agent.git_providers.local_git_provider import LocalGitProvider @@ -22,7 +23,7 @@ _GIT_PROVIDERS = { 'codecommit': CodeCommitProvider, 'local': LocalGitProvider, 'gerrit': GerritProvider, - 'gitea': GiteaProvider, + 'gitea': GiteaProvider } diff --git a/pr_agent/git_providers/gitea_provider.py b/pr_agent/git_providers/gitea_provider.py index 1d671558..8805d8f4 100644 --- a/pr_agent/git_providers/gitea_provider.py +++ b/pr_agent/git_providers/gitea_provider.py @@ -1,258 +1,992 @@ -from typing import Optional, Tuple, List, Dict +import hashlib +import json +from typing import Any, Dict, List, Optional, Set, Tuple from urllib.parse import urlparse -import requests -from pr_agent.git_providers.git_provider import GitProvider + +import giteapy +from giteapy.rest import ApiException + +from pr_agent.algo.file_filter import filter_ignored +from pr_agent.algo.language_handler import is_valid_file +from pr_agent.algo.types import EDIT_TYPE +from pr_agent.algo.utils import (clip_tokens, + find_line_number_of_relevant_line_in_file) from pr_agent.config_loader import get_settings +from pr_agent.git_providers.git_provider import (MAX_FILES_ALLOWED_FULL, + FilePatchInfo, GitProvider, + IncrementalPR) from pr_agent.log import get_logger -from pr_agent.algo.types import EDIT_TYPE, FilePatchInfo class GiteaProvider(GitProvider): - """ - Implements GitProvider for Gitea/Forgejo API v1. - """ + def __init__(self, url: Optional[str] = None): + super().__init__() + self.logger = get_logger() - def __init__(self, pr_url: Optional[str] = None, incremental: Optional[bool] = False): - self.gitea_url = get_settings().get("GITEA.URL", None) - self.gitea_token = get_settings().get("GITEA.TOKEN", None) - if not self.gitea_url: - raise ValueError("GITEA.URL is not set in the config file") - if not self.gitea_token: - raise ValueError("GITEA.TOKEN is not set in the config file") - self.headers = { - 'Authorization': f'token {self.gitea_token}', - 'Content-Type': 'application/json', - 'Accept': 'application/json' - } + if not url: + self.logger.error("PR URL not provided.") + raise ValueError("PR URL not provided.") + + self.base_url = get_settings().get("GITEA.URL", "https://gitea.com").rstrip("/") + self.pr_url = "" + self.issue_url = "" + + gitea_access_token = get_settings().get("GITEA.PERSONAL_ACCESS_TOKEN", None) + if not gitea_access_token: + self.logger.error("Gitea access token not found in settings.") + raise ValueError("Gitea access token not found in settings.") + + self.repo_settings = get_settings().get("GITEA.REPO_SETTING", None) + configuration = giteapy.Configuration() + configuration.host = "{}/api/v1".format(self.base_url) + configuration.api_key['Authorization'] = f'token {gitea_access_token}' + + client = giteapy.ApiClient(configuration) + self.repo_api = RepoApi(client) self.owner = None self.repo = None - self.pr_num = None + self.pr_number = None + self.issue_number = None + self.max_comment_chars = 65000 + self.enabled_pr = False + self.enabled_issue = False + self.temp_comments = [] self.pr = None - self.pr_url = pr_url - self.incremental = incremental - if pr_url: - self.set_pr(pr_url) + self.git_files = [] + self.file_contents = {} + self.file_diffs = {} + self.sha = None + self.diff_files = [] + self.incremental = IncrementalPR(False) + self.comments_list = [] + self.unreviewed_files_set = dict() - @staticmethod - def _parse_pr_url(pr_url: str) -> Tuple[str, str, str]: - """ - Parse Gitea PR URL to (owner, repo, pr_number) - """ + if "pulls" in url: + self.pr_url = url + self.__set_repo_and_owner_from_pr() + self.enabled_pr = True + self.pr = self.repo_api.get_pull_request( + owner=self.owner, + repo=self.repo, + pr_number=self.pr_number + ) + self.git_files = self.repo_api.get_change_file_pull_request( + owner=self.owner, + repo=self.repo, + pr_number=self.pr_number + ) + # Optional ignore with user custom + self.git_files = filter_ignored(self.git_files, platform="gitea") + + self.sha = self.pr.head.sha if self.pr.head.sha else "" + self.__add_file_content() + self.__add_file_diff() + self.pr_commits = self.repo_api.list_all_commits( + owner=self.owner, + repo=self.repo + ) + self.last_commit = self.pr_commits[-1] + self.base_sha = self.pr.base.sha if self.pr.base.sha else "" + self.base_ref = self.pr.base.ref if self.pr.base.ref else "" + elif "issues" in url: + self.issue_url = url + self.__set_repo_and_owner_from_issue() + self.enabled_issue = True + else: + self.pr_commits = None + + def __add_file_content(self): + for file in self.git_files: + file_path = file.get("filename") + # Ignore file from default settings + if not is_valid_file(file_path): + continue + + if file_path and self.sha: + try: + content = self.repo_api.get_file_content( + owner=self.owner, + repo=self.repo, + commit_sha=self.sha, + filepath=file_path + ) + self.file_contents[file_path] = content + except ApiException as e: + self.logger.error(f"Error getting file content for {file_path}: {str(e)}") + self.file_contents[file_path] = "" + + def __add_file_diff(self): + try: + diff_contents = self.repo_api.get_pull_request_diff( + owner=self.owner, + repo=self.repo, + pr_number=self.pr_number + ) + + lines = diff_contents.splitlines() + current_file = None + current_patch = [] + file_patches = {} + for line in lines: + if line.startswith('diff --git'): + if current_file and current_patch: + file_patches[current_file] = '\n'.join(current_patch) + current_patch = [] + current_file = line.split(' b/')[-1] + elif line.startswith('@@'): + current_patch = [line] + elif current_patch: + current_patch.append(line) + + if current_file and current_patch: + file_patches[current_file] = '\n'.join(current_patch) + + self.file_diffs = file_patches + except Exception as e: + self.logger.error(f"Error getting diff content: {str(e)}") + + def _parse_pr_url(self, pr_url: str) -> Tuple[str, str, int]: parsed_url = urlparse(pr_url) + + if parsed_url.path.startswith('/api/v1'): + parsed_url = urlparse(pr_url.replace("/api/v1", "")) + path_parts = parsed_url.path.strip('/').split('/') if len(path_parts) < 4 or path_parts[2] != 'pulls': - raise ValueError(f"Invalid PR URL format: {pr_url}") - return path_parts[0], path_parts[1], path_parts[3] + raise ValueError("The provided URL does not appear to be a Gitea PR URL") - def set_pr(self, pr_url: str): - self.owner, self.repo, self.pr_num = self._parse_pr_url(pr_url) - self.pr = self._get_pr() + try: + pr_number = int(path_parts[3]) + except ValueError as e: + raise ValueError("Unable to convert PR number to integer") from e - def _get_pr(self): - url = f"{self.gitea_url}/api/v1/repos/{self.owner}/{self.repo}/pulls/{self.pr_num}" - response = requests.get(url, headers=self.headers) - response.raise_for_status() - return response.json() + owner = path_parts[0] + repo = path_parts[1] - def is_supported(self, capability: str) -> bool: - # Gitea/Forgejo supports most capabilities - return True + return owner, repo, pr_number - def get_files(self) -> List[str]: - url = f"{self.gitea_url}/api/v1/repos/{self.owner}/{self.repo}/pulls/{self.pr_num}/files" - response = requests.get(url, headers=self.headers) - response.raise_for_status() - return [file['filename'] for file in response.json()] + def _parse_issue_url(self, issue_url: str) -> Tuple[str, str, int]: + parsed_url = urlparse(issue_url) - def get_diff_files(self) -> List[FilePatchInfo]: - url = f"{self.gitea_url}/api/v1/repos/{self.owner}/{self.repo}/pulls/{self.pr_num}/files" - response = requests.get(url, headers=self.headers) - response.raise_for_status() + if parsed_url.path.startswith('/api/v1'): + parsed_url = urlparse(issue_url.replace("/api/v1", "")) - diff_files = [] - for file in response.json(): - edit_type = EDIT_TYPE.MODIFIED - if file.get('status') == 'added': - edit_type = EDIT_TYPE.ADDED - elif file.get('status') == 'deleted': - edit_type = EDIT_TYPE.DELETED - elif file.get('status') == 'renamed': - edit_type = EDIT_TYPE.RENAMED + path_parts = parsed_url.path.strip('/').split('/') + if len(path_parts) < 4 or path_parts[2] != 'issues': + raise ValueError("The provided URL does not appear to be a Gitea issue URL") - diff_files.append( - FilePatchInfo( - file.get('previous_filename', ''), - file.get('filename', ''), - file.get('patch', ''), - file['filename'], - edit_type=edit_type, - old_filename=file.get('previous_filename') - ) - ) - return diff_files + try: + issue_number = int(path_parts[3]) + except ValueError as e: + raise ValueError("Unable to convert issue number to integer") from e - def publish_description(self, pr_title: str, pr_body: str): - url = f"{self.gitea_url}/api/v1/repos/{self.owner}/{self.repo}/pulls/{self.pr_num}" - data = {'title': pr_title, 'body': pr_body} - response = requests.patch(url, headers=self.headers, json=data) - response.raise_for_status() + owner = path_parts[0] + repo = path_parts[1] - def publish_comment(self, pr_comment: str, is_temporary: bool = False): - url = f"{self.gitea_url}/api/v1/repos/{self.owner}/{self.repo}/issues/{self.pr_num}/comments" - data = {'body': pr_comment} - response = requests.post(url, headers=self.headers, json=data) - response.raise_for_status() + return owner, repo, issue_number - def publish_inline_comment(self, body: str, relevant_file: str, relevant_line_in_file: str, - original_suggestion=None): - url = f"{self.gitea_url}/api/v1/repos/{self.owner}/{self.repo}/pulls/{self.pr_num}/reviews" + def __set_repo_and_owner_from_pr(self): + """Extract owner and repo from the PR URL""" + try: + owner, repo, pr_number = self._parse_pr_url(self.pr_url) + self.owner = owner + self.repo = repo + self.pr_number = pr_number + self.logger.info(f"Owner: {self.owner}, Repo: {self.repo}, PR Number: {self.pr_number}") + except ValueError as e: + self.logger.error(f"Error parsing PR URL: {str(e)}") + except Exception as e: + self.logger.error(f"Unexpected error: {str(e)}") - data = { - 'event': 'COMMENT', - 'body': original_suggestion or '', - 'commit_id': self.pr.get('head', {}).get('sha', ''), - 'comments': [{ - 'body': body, - 'path': relevant_file, - 'line': int(relevant_line_in_file) - }] + def __set_repo_and_owner_from_issue(self): + """Extract owner and repo from the issue URL""" + try: + owner, repo, issue_number = self._parse_issue_url(self.issue_url) + self.owner = owner + self.repo = repo + self.issue_number = issue_number + self.logger.info(f"Owner: {self.owner}, Repo: {self.repo}, Issue Number: {self.issue_number}") + except ValueError as e: + self.logger.error(f"Error parsing issue URL: {str(e)}") + except Exception as e: + self.logger.error(f"Unexpected error: {str(e)}") + + def get_pr_url(self) -> str: + return self.pr_url + + def get_issue_url(self) -> str: + return self.issue_url + + def publish_comment(self, comment: str,is_temporary: bool = False) -> None: + """Publish a comment to the pull request""" + if is_temporary and not get_settings().config.publish_output_progress: + get_logger().debug(f"Skipping publish_comment for temporary comment") + return None + + if self.enabled_issue: + index = self.issue_number + elif self.enabled_pr: + index = self.pr_number + else: + self.logger.error("Neither PR nor issue URL provided.") + return None + + comment = self.limit_output_characters(comment, self.max_comment_chars) + response = self.repo_api.create_comment( + owner=self.owner, + repo=self.repo, + index=index, + comment=comment + ) + + if not response: + self.logger.error("Failed to publish comment") + return None + + if is_temporary: + self.temp_comments.append(comment) + + comment_obj = { + "is_temporary": is_temporary, + "comment": comment, + "comment_id": response.id if isinstance(response, tuple) else response.id } - response = requests.post(url, headers=self.headers, json=data) - response.raise_for_status() + self.comments_list.append(comment_obj) + self.logger.info("Comment published") + return comment_obj - def publish_inline_comments(self, comments: list[dict]): - for comment in comments: - try: - self.publish_inline_comment( - comment['body'], - comment['relevant_file'], - comment['relevant_line_in_file'], - comment.get('original_suggestion') - ) - except Exception as e: - get_logger().error(f"Failed to publish inline comment on {comment.get('relevant_file')}: {e}") + def edit_comment(self, comment, body : str): + body = self.limit_output_characters(body, self.max_comment_chars) + try: + self.repo_api.edit_comment( + owner=self.owner, + repo=self.repo, + comment_id=comment.get("comment_id") if isinstance(comment, dict) else comment.id, + comment=body + ) + except ApiException as e: + self.logger.error(f"Error editing comment: {e}") + return None + except Exception as e: + self.logger.error(f"Unexpected error: {e}") + return None - def publish_code_suggestions(self, code_suggestions: list) -> bool: - overall_success = True - for suggestion in code_suggestions: - try: - self.publish_inline_comment( - suggestion['body'], - suggestion['relevant_file'], - suggestion['relevant_line_in_file'], - suggestion.get('original_suggestion') - ) - except Exception as e: - overall_success = False - get_logger().error( - f"Failed to publish code suggestion on {suggestion.get('relevant_file')}: {e}") - return overall_success - def publish_labels(self, labels): - url = f"{self.gitea_url}/api/v1/repos/{self.owner}/{self.repo}/issues/{self.pr_num}/labels" - data = {'labels': labels} - response = requests.post(url, headers=self.headers, json=data) - response.raise_for_status() + def publish_inline_comment(self,body: str, relevant_file: str, relevant_line_in_file: str, original_suggestion=None): + """Publish an inline comment on a specific line""" + body = self.limit_output_characters(body, self.max_comment_chars) + position, absolute_position = find_line_number_of_relevant_line_in_file(self.diff_files, + relevant_file.strip('`'), + relevant_line_in_file, + ) + if position == -1: + get_logger().info(f"Could not find position for {relevant_file} {relevant_line_in_file}") + subject_type = "FILE" + else: + subject_type = "LINE" - def get_pr_labels(self, update=False): - url = f"{self.gitea_url}/api/v1/repos/{self.owner}/{self.repo}/issues/{self.pr_num}/labels" - response = requests.get(url, headers=self.headers) - response.raise_for_status() - return [label['name'] for label in response.json()] + path = relevant_file.strip() + payload = dict(body=body, path=path, old_position=position,new_position = absolute_position) if subject_type == "LINE" else {} + self.publish_inline_comments([payload]) - def get_issue_comments(self): - url = f"{self.gitea_url}/api/v1/repos/{self.owner}/{self.repo}/issues/{self.pr_num}/comments" - response = requests.get(url, headers=self.headers) - response.raise_for_status() - return response.json() - def remove_initial_comment(self): - # Implementation depends on how you track the initial comment - pass + def publish_inline_comments(self, comments: List[Dict[str, Any]],body : str = "Inline comment") -> None: + response = self.repo_api.create_inline_comment( + owner=self.owner, + repo=self.repo, + pr_number=self.pr_number if self.enabled_pr else self.issue_number, + body=body, + commit_id=self.last_commit.sha if self.last_commit else "", + comments=comments + ) - def remove_comment(self, comment): - url = f"{self.gitea_url}/api/v1/repos/{self.owner}/{self.repo}/issues/comments/{comment['id']}" - response = requests.delete(url, headers=self.headers) - response.raise_for_status() + if not response: + self.logger.error("Failed to publish inline comment") + return None + + self.logger.info("Inline comment published") + + def publish_code_suggestions(self, suggestions: List[Dict[str, Any]]): + """Publish code suggestions""" + for suggestion in suggestions: + body = suggestion.get("body","") + if not body: + self.logger.error("No body provided for the suggestion") + continue + + path = suggestion.get("relevant_file","") + new_position = suggestion.get("relevant_lines_start",0) + old_position = suggestion.get("relevant_lines_start",0) if "original_suggestion" not in suggestion else suggestion["original_suggestion"].get("relevant_lines_start",0) + title_body = suggestion["original_suggestion"].get("suggestion_content","") if "original_suggestion" in suggestion else "" + payload = dict(body=body, path=path, old_position=old_position,new_position = new_position) + if title_body: + title_body = f"**Suggestion:** {title_body}" + self.publish_inline_comments([payload],title_body) + else: + self.publish_inline_comments([payload]) def add_eyes_reaction(self, issue_comment_id: int, disable_eyes: bool = False) -> Optional[int]: - if disable_eyes: - return None - url = f"{self.gitea_url}/api/v1/repos/{self.owner}/{self.repo}/issues/comments/{issue_comment_id}/reactions" - data = {'content': 'eyes'} - response = requests.post(url, headers=self.headers, json=data) - response.raise_for_status() - return response.json()['id'] - - def remove_reaction(self, issue_comment_id: int, reaction_id: int) -> bool: - url = f"{self.gitea_url}/api/v1/repos/{self.owner}/{self.repo}/issues/comments/{issue_comment_id}/reactions/{reaction_id}" - response = requests.delete(url, headers=self.headers) - return response.status_code == 204 - - def get_commit_messages(self): - url = f"{self.gitea_url}/api/v1/repos/{self.owner}/{self.repo}/pulls/{self.pr_num}/commits" - response = requests.get(url, headers=self.headers) - response.raise_for_status() - return [commit['commit']['message'] for commit in response.json()] - - def get_pr_branch(self): - return self.pr['head']['ref'] - - def get_user_id(self): - return self.pr['user']['id'] - - def get_pr_description_full(self) -> str: - return self.pr['body'] or '' - - def get_git_repo_url(self, issues_or_pr_url: str) -> str: + """Add eyes reaction to a comment""" try: - parsed_url = urlparse(issues_or_pr_url) - path_parts = parsed_url.path.strip('/').split('/') - if len(path_parts) < 2: - raise ValueError(f"Invalid URL format: {issues_or_pr_url}") - return f"{parsed_url.scheme}://{parsed_url.netloc}/{path_parts[0]}/{path_parts[1]}.git" + if disable_eyes: + return None + + comments = self.repo_api.list_all_comments( + owner=self.owner, + repo=self.repo, + index=self.pr_number if self.enabled_pr else self.issue_number + ) + + comment_ids = [comment.id for comment in comments] + if issue_comment_id not in comment_ids: + self.logger.error(f"Comment ID {issue_comment_id} not found. Available IDs: {comment_ids}") + return None + + response = self.repo_api.add_reaction_comment( + owner=self.owner, + repo=self.repo, + comment_id=issue_comment_id, + reaction="eyes" + ) + + if not response: + self.logger.error("Failed to add eyes reaction") + return None + + return response[0].id if isinstance(response, tuple) else response.id + + except ApiException as e: + self.logger.error(f"Error adding eyes reaction: {e}") + return None except Exception as e: - get_logger().exception(f"Failed to get git repo URL from: {issues_or_pr_url}") + self.logger.error(f"Unexpected error: {e}") + return None + + def remove_reaction(self, comment_id: int) -> None: + """Remove reaction from a comment""" + try: + response = self.repo_api.remove_reaction_comment( + owner=self.owner, + repo=self.repo, + comment_id=comment_id + ) + if not response: + self.logger.error("Failed to remove reaction") + except ApiException as e: + self.logger.error(f"Error removing reaction: {e}") + except Exception as e: + self.logger.error(f"Unexpected error: {e}") + + def get_commit_messages(self)-> str: + """Get commit messages for the PR""" + max_tokens = get_settings().get("CONFIG.MAX_COMMITS_TOKENS", None) + pr_commits = self.repo_api.get_pr_commits( + owner=self.owner, + repo=self.repo, + pr_number=self.pr_number + ) + + if not pr_commits: + self.logger.error("Failed to get commit messages") return "" - def get_canonical_url_parts(self, repo_git_url: str, desired_branch: str) -> Tuple[str, str]: try: - parsed_url = urlparse(repo_git_url) - path_parts = parsed_url.path.strip('/').split('/') - if len(path_parts) < 2: - raise ValueError(f"Invalid git repo URL format: {repo_git_url}") + commit_messages = [commit["commit"]["message"] for commit in pr_commits if commit] - repo_name = path_parts[1] - if repo_name.endswith('.git'): - repo_name = repo_name[:-4] + if not commit_messages: + self.logger.error("No commit messages found") + return "" - prefix = f"{parsed_url.scheme}://{parsed_url.netloc}/{path_parts[0]}/{repo_name}/src/branch/{desired_branch}" - suffix = "" - return prefix, suffix + commit_message = "".join(commit_messages) + if max_tokens: + commit_message = clip_tokens(commit_message, max_tokens) + + return commit_message except Exception as e: - get_logger().exception(f"Failed to get canonical URL parts from: {repo_git_url}") - return ("", "") + self.logger.error(f"Error processing commit messages: {str(e)}") + return "" - def get_languages(self) -> Dict[str, float]: - """ - Get the languages used in the repository and their percentages. - Returns a dictionary mapping language names to their percentage of use. - """ - if not self.owner or not self.repo: - return {} - url = f"{self.gitea_url}/api/v1/repos/{self.owner}/{self.repo}/languages" - response = requests.get(url, headers=self.headers) - response.raise_for_status() - return response.json() + def _get_file_content_from_base(self, filename: str) -> str: + return self.repo_api.get_file_content( + owner=self.owner, + repo=self.base_ref, + commit_sha=self.base_sha, + filepath=filename + ) + + def _get_file_content_from_latest_commit(self, filename: str) -> str: + return self.repo_api.get_file_content( + owner=self.owner, + repo=self.base_ref, + commit_sha=self.last_commit.sha, + filepath=filename + ) + + def get_diff_files(self) -> List[FilePatchInfo]: + """Get files that were modified in the PR""" + if self.diff_files: + return self.diff_files + + invalid_files_names = [] + counter_valid = 0 + diff_files = [] + for file in self.git_files: + filename = file.get("filename") + if not filename: + continue + + if not is_valid_file(filename): + invalid_files_names.append(filename) + continue + + counter_valid += 1 + avoid_load = False + patch = self.file_diffs.get(filename,"") + head_file = "" + base_file = "" + + if counter_valid >= MAX_FILES_ALLOWED_FULL and patch and not self.incremental.is_incremental: + avoid_load = True + if counter_valid == MAX_FILES_ALLOWED_FULL: + self.logger.info("Too many files in PR, will avoid loading full content for rest of files") + + if avoid_load: + head_file = "" + else: + # Get file content from this pr + head_file = self.file_contents.get(filename,"") + + if self.incremental.is_incremental and self.unreviewed_files_set: + base_file = self._get_file_content_from_latest_commit(filename) + self.unreviewed_files_set[filename] = patch + else: + if avoid_load: + base_file = "" + else: + base_file = self._get_file_content_from_base(filename) + + num_plus_lines = file.get("additions",0) + num_minus_lines = file.get("deletions",0) + status = file.get("status","") + + if status == 'added': + edit_type = EDIT_TYPE.ADDED + elif status == 'removed': + edit_type = EDIT_TYPE.DELETED + elif status == 'renamed': + edit_type = EDIT_TYPE.RENAMED + elif status == 'modified': + edit_type = EDIT_TYPE.MODIFIED + else: + self.logger.error(f"Unknown edit type: {status}") + edit_type = EDIT_TYPE.UNKNOWN + + file_patch_info = FilePatchInfo( + base_file=base_file, + head_file=head_file, + patch=patch, + filename=filename, + num_minus_lines=num_minus_lines, + num_plus_lines=num_plus_lines, + edit_type=edit_type + ) + diff_files.append(file_patch_info) + + if invalid_files_names: + self.logger.info(f"Filtered out files with invalid extensions: {invalid_files_names}") + + self.diff_files = diff_files + return diff_files + + def get_line_link(self, relevant_file, relevant_line_start, relevant_line_end = None) -> str: + if relevant_line_start == -1: + link = f"{self.base_url}/{self.owner}/{self.repo}/src/branch/{self.get_pr_branch()}/{relevant_file}" + elif relevant_line_end: + link = f"{self.base_url}/{self.owner}/{self.repo}/src/branch/{self.get_pr_branch()}/{relevant_file}#L{relevant_line_start}-L{relevant_line_end}" + else: + link = f"{self.base_url}/{self.owner}/{self.repo}/src/branch/{self.get_pr_branch()}/{relevant_file}#L{relevant_line_start}" + + self.logger.info(f"Generated link: {link}") + return link + + def get_files(self) -> List[Dict[str, Any]]: + """Get all files in the PR""" + return [file.get("filename","") for file in self.git_files] + + def get_num_of_files(self) -> int: + """Get number of files changed in the PR""" + return len(self.git_files) + + def get_issue_comments(self) -> List[Dict[str, Any]]: + """Get all comments in the PR""" + index = self.issue_number if self.enabled_issue else self.pr_number + comments = self.repo_api.list_all_comments( + owner=self.owner, + repo=self.repo, + index=index + ) + if not comments: + self.logger.error("Failed to get comments") + return [] + + return comments + + def get_languages(self) -> Set[str]: + """Get programming languages used in the repository""" + languages = self.repo_api.get_languages( + owner=self.owner, + repo=self.repo + ) + + return languages + + def get_pr_branch(self) -> str: + """Get the branch name of the PR""" + if not self.pr: + self.logger.error("Failed to get PR branch") + return "" + + if not self.pr.head: + self.logger.error("PR head not found") + return "" + + return self.pr.head.ref if self.pr.head.ref else "" + + def get_pr_description_full(self) -> str: + """Get full PR description with metadata""" + if not self.pr: + self.logger.error("Failed to get PR description") + return "" + + return self.pr.body if self.pr.body else "" + + def get_pr_labels(self,update=False) -> List[str]: + """Get labels assigned to the PR""" + if not update: + if not self.pr.labels: + self.logger.error("Failed to get PR labels") + return [] + return [label.name for label in self.pr.labels] + + labels = self.repo_api.get_issue_labels( + owner=self.owner, + repo=self.repo, + issue_number=self.pr_number + ) + if not labels: + self.logger.error("Failed to get PR labels") + return [] + + return [label.name for label in labels] + + def get_repo_settings(self) -> str: + """Get repository settings""" + if not self.repo_settings: + self.logger.error("Repository settings not found") + return "" + + response = self.repo_api.get_file_content( + owner=self.owner, + repo=self.repo, + commit_sha=self.sha, + filepath=self.repo_settings + ) + if not response: + self.logger.error("Failed to get repository settings") + return "" + + return response + + def get_user_id(self) -> str: + """Get the ID of the authenticated user""" + return f"{self.pr.user.id}" if self.pr else "" + + def is_supported(self, capability) -> bool: + """Check if the provider is supported""" + return True + + def publish_description(self, pr_title: str, pr_body: str) -> None: + """Publish PR description""" + response = self.repo_api.edit_pull_request( + owner=self.owner, + repo=self.repo, + pr_number=self.pr_number if self.enabled_pr else self.issue_number, + title=pr_title, + body=pr_body + ) + + if not response: + self.logger.error("Failed to publish PR description") + return None + + self.logger.info("PR description published successfully") + if self.enabled_pr: + self.pr = self.repo_api.get_pull_request( + owner=self.owner, + repo=self.repo, + pr_number=self.pr_number + ) + + def publish_labels(self, labels: List[int]) -> None: + """Publish labels to the PR""" + if not labels: + self.logger.error("No labels provided to publish") + return None + + response = self.repo_api.add_labels( + owner=self.owner, + repo=self.repo, + issue_number=self.pr_number if self.enabled_pr else self.issue_number, + labels=labels + ) + + if response: + self.logger.info("Labels added successfully") + + def remove_comment(self, comment) -> None: + """Remove a specific comment""" + if not comment: + return + + try: + comment_id = comment.get("comment_id") if isinstance(comment, dict) else comment.id + if not comment_id: + self.logger.error("Comment ID not found") + return None + self.repo_api.remove_comment( + owner=self.owner, + repo=self.repo, + comment_id=comment_id + ) + + if self.comments_list and comment in self.comments_list: + self.comments_list.remove(comment) + + self.logger.info(f"Comment removed successfully: {comment}") + except ApiException as e: + self.logger.error(f"Error removing comment: {e}") + raise e + + def remove_initial_comment(self) -> None: + """Remove the initial comment""" + for comment in self.comments_list: + try: + if not comment.get("is_temporary"): + continue + self.remove_comment(comment) + except Exception as e: + self.logger.error(f"Error removing comment: {e}") + continue + self.logger.info(f"Removed initial comment: {comment.get('comment_id')}") + + +class RepoApi(giteapy.RepositoryApi): + def __init__(self, client: giteapy.ApiClient): + self.repository = giteapy.RepositoryApi(client) + self.issue = giteapy.IssueApi(client) + self.logger = get_logger() + super().__init__(client) + + def create_inline_comment(self, owner: str, repo: str, pr_number: int, body : str ,commit_id : str, comments: List[Dict[str, Any]]) -> None: + body = { + "body": body, + "comments": comments, + "commit_id": commit_id, + } + return self.api_client.call_api( + '/repos/{owner}/{repo}/pulls/{pr_number}/reviews', + 'POST', + path_params={'owner': owner, 'repo': repo, 'pr_number': pr_number}, + body=body, + response_type='Repository', + auth_settings=['AuthorizationHeaderToken'] + ) + + def create_comment(self, owner: str, repo: str, index: int, comment: str): + body = { + "body": comment + } + return self.issue.issue_create_comment( + owner=owner, + repo=repo, + index=index, + body=body + ) + + def edit_comment(self, owner: str, repo: str, comment_id: int, comment: str): + body = { + "body": comment + } + return self.issue.issue_edit_comment( + owner=owner, + repo=repo, + id=comment_id, + body=body + ) + + def remove_comment(self, owner: str, repo: str, comment_id: int): + return self.issue.issue_delete_comment( + owner=owner, + repo=repo, + id=comment_id + ) + + def list_all_comments(self, owner: str, repo: str, index: int): + return self.issue.issue_get_comments( + owner=owner, + repo=repo, + index=index + ) + + def get_pull_request_diff(self, owner: str, repo: str, pr_number: int) -> str: + """Get the diff content of a pull request using direct API call""" + try: + token = self.api_client.configuration.api_key.get('Authorization', '').replace('token ', '') + url = f'/repos/{owner}/{repo}/pulls/{pr_number}.diff' + if token: + url = f'{url}?token={token}' + + response = self.api_client.call_api( + url, + 'GET', + path_params={}, + response_type=None, + _return_http_data_only=False, + _preload_content=False + ) + + if hasattr(response, 'data'): + raw_data = response.data.read() + return raw_data.decode('utf-8') + elif isinstance(response, tuple): + raw_data = response[0].read() + return raw_data.decode('utf-8') + else: + error_msg = f"Unexpected response format received from API: {type(response)}" + self.logger.error(error_msg) + raise RuntimeError(error_msg) + + except ApiException as e: + self.logger.error(f"Error getting diff: {str(e)}") + raise e + except Exception as e: + self.logger.error(f"Unexpected error: {str(e)}") + raise e + + def get_pull_request(self, owner: str, repo: str, pr_number: int): + """Get pull request details including description""" + return self.repository.repo_get_pull_request( + owner=owner, + repo=repo, + index=pr_number + ) + + def edit_pull_request(self, owner: str, repo: str, pr_number: int,title : str, body: str): + """Edit pull request description""" + body = { + "body": body, + "title" : title + } + return self.repository.repo_edit_pull_request( + owner=owner, + repo=repo, + index=pr_number, + body=body + ) + + def get_change_file_pull_request(self, owner: str, repo: str, pr_number: int): + """Get changed files in the pull request""" + try: + token = self.api_client.configuration.api_key.get('Authorization', '').replace('token ', '') + url = f'/repos/{owner}/{repo}/pulls/{pr_number}/files' + if token: + url = f'{url}?token={token}' + + response = self.api_client.call_api( + url, + 'GET', + path_params={}, + response_type=None, + _return_http_data_only=False, + _preload_content=False + ) + + if hasattr(response, 'data'): + raw_data = response.data.read() + diff_content = raw_data.decode('utf-8') + return json.loads(diff_content) if isinstance(diff_content, str) else diff_content + elif isinstance(response, tuple): + raw_data = response[0].read() + diff_content = raw_data.decode('utf-8') + return json.loads(diff_content) if isinstance(diff_content, str) else diff_content + + return [] + + except ApiException as e: + self.logger.error(f"Error getting changed files: {e}") + return [] + except Exception as e: + self.logger.error(f"Unexpected error: {e}") + return [] + + def get_languages(self, owner: str, repo: str): + """Get programming languages used in the repository""" + try: + token = self.api_client.configuration.api_key.get('Authorization', '').replace('token ', '') + url = f'/repos/{owner}/{repo}/languages' + if token: + url = f'{url}?token={token}' + + response = self.api_client.call_api( + url, + 'GET', + path_params={}, + response_type=None, + _return_http_data_only=False, + _preload_content=False + ) + + if hasattr(response, 'data'): + raw_data = response.data.read() + return json.loads(raw_data.decode('utf-8')) + elif isinstance(response, tuple): + raw_data = response[0].read() + return json.loads(raw_data.decode('utf-8')) - def get_repo_settings(self) -> Dict: - """ - Get repository settings and configuration. - Returns a dictionary containing repository settings. - """ - if not self.owner or not self.repo: return {} - url = f"{self.gitea_url}/api/v1/repos/{self.owner}/{self.repo}" - response = requests.get(url, headers=self.headers) - response.raise_for_status() - return response.json() + + except ApiException as e: + self.logger.error(f"Error getting languages: {e}") + return {} + except Exception as e: + self.logger.error(f"Unexpected error: {e}") + return {} + + def get_file_content(self, owner: str, repo: str, commit_sha: str, filepath: str) -> str: + """Get raw file content from a specific commit""" + + try: + token = self.api_client.configuration.api_key.get('Authorization', '').replace('token ', '') + url = f'/repos/{owner}/{repo}/raw/{filepath}' + if token: + url = f'{url}?token={token}&ref={commit_sha}' + + response = self.api_client.call_api( + url, + 'GET', + path_params={}, + response_type=None, + _return_http_data_only=False, + _preload_content=False + ) + + if hasattr(response, 'data'): + raw_data = response.data.read() + return raw_data.decode('utf-8') + elif isinstance(response, tuple): + raw_data = response[0].read() + return raw_data.decode('utf-8') + + return "" + + except ApiException as e: + self.logger.error(f"Error getting file: {filepath}, content: {e}") + return "" + except Exception as e: + self.logger.error(f"Unexpected error: {e}") + return "" + + def get_issue_labels(self, owner: str, repo: str, issue_number: int): + """Get labels assigned to the issue""" + return self.issue.issue_get_labels( + owner=owner, + repo=repo, + index=issue_number + ) + + def list_all_commits(self, owner: str, repo: str): + return self.repository.repo_get_all_commits( + owner=owner, + repo=repo + ) + + def add_reviewer(self, owner: str, repo: str, pr_number: int, reviewers: List[str]): + body = { + "reviewers": reviewers + } + return self.api_client.call_api( + '/repos/{owner}/{repo}/pulls/{pr_number}/requested_reviewers', + 'POST', + path_params={'owner': owner, 'repo': repo, 'pr_number': pr_number}, + body=body, + response_type='Repository', + auth_settings=['AuthorizationHeaderToken'] + ) + + def add_reaction_comment(self, owner: str, repo: str, comment_id: int, reaction: str): + body = { + "content": reaction + } + return self.api_client.call_api( + '/repos/{owner}/{repo}/issues/comments/{id}/reactions', + 'POST', + path_params={'owner': owner, 'repo': repo, 'id': comment_id}, + body=body, + response_type='Repository', + auth_settings=['AuthorizationHeaderToken'] + ) + + def remove_reaction_comment(self, owner: str, repo: str, comment_id: int): + return self.api_client.call_api( + '/repos/{owner}/{repo}/issues/comments/{id}/reactions', + 'DELETE', + path_params={'owner': owner, 'repo': repo, 'id': comment_id}, + response_type='Repository', + auth_settings=['AuthorizationHeaderToken'] + ) + + def add_labels(self, owner: str, repo: str, issue_number: int, labels: List[int]): + body = { + "labels": labels + } + return self.issue.issue_add_label( + owner=owner, + repo=repo, + index=issue_number, + body=body + ) + + def get_pr_commits(self, owner: str, repo: str, pr_number: int): + """Get all commits in a pull request""" + try: + token = self.api_client.configuration.api_key.get('Authorization', '').replace('token ', '') + url = f'/repos/{owner}/{repo}/pulls/{pr_number}/commits' + if token: + url = f'{url}?token={token}' + + response = self.api_client.call_api( + url, + 'GET', + path_params={}, + response_type=None, + _return_http_data_only=False, + _preload_content=False + ) + + if hasattr(response, 'data'): + raw_data = response.data.read() + commits_data = json.loads(raw_data.decode('utf-8')) + return commits_data + elif isinstance(response, tuple): + raw_data = response[0].read() + commits_data = json.loads(raw_data.decode('utf-8')) + return commits_data + + return [] + + except ApiException as e: + self.logger.error(f"Error getting PR commits: {e}") + return [] + except Exception as e: + self.logger.error(f"Unexpected error: {e}") + return [] diff --git a/pr_agent/servers/gitea_app.py b/pr_agent/servers/gitea_app.py new file mode 100644 index 00000000..018a746d --- /dev/null +++ b/pr_agent/servers/gitea_app.py @@ -0,0 +1,128 @@ +import asyncio +import copy +import os +from typing import Any, Dict + +from fastapi import APIRouter, FastAPI, HTTPException, Request, Response +from starlette.background import BackgroundTasks +from starlette.middleware import Middleware +from starlette_context import context +from starlette_context.middleware import RawContextMiddleware + +from pr_agent.agent.pr_agent import PRAgent +from pr_agent.config_loader import get_settings, global_settings +from pr_agent.log import LoggingFormat, get_logger, setup_logger +from pr_agent.servers.utils import verify_signature + +# Setup logging and router +setup_logger(fmt=LoggingFormat.JSON, level=get_settings().get("CONFIG.LOG_LEVEL", "DEBUG")) +router = APIRouter() + +@router.post("/api/v1/gitea_webhooks") +async def handle_gitea_webhooks(background_tasks: BackgroundTasks, request: Request, response: Response): + """Handle incoming Gitea webhook requests""" + get_logger().debug("Received a Gitea webhook") + + body = await get_body(request) + + # Set context for the request + context["settings"] = copy.deepcopy(global_settings) + context["git_provider"] = {} + + # Handle the webhook in background + background_tasks.add_task(handle_request, body, event=request.headers.get("X-Gitea-Event", None)) + return {} + +async def get_body(request: Request): + """Parse and verify webhook request body""" + try: + body = await request.json() + except Exception as e: + get_logger().error("Error parsing request body", artifact={'error': e}) + raise HTTPException(status_code=400, detail="Error parsing request body") from e + + + # Verify webhook signature + webhook_secret = getattr(get_settings().gitea, 'webhook_secret', None) + if webhook_secret: + body_bytes = await request.body() + signature_header = request.headers.get('x-gitea-signature', None) + if not signature_header: + get_logger().error("Missing signature header") + raise HTTPException(status_code=400, detail="Missing signature header") + + try: + verify_signature(body_bytes, webhook_secret, f"sha256={signature_header}") + except Exception as ex: + get_logger().error(f"Invalid signature: {ex}") + raise HTTPException(status_code=401, detail="Invalid signature") + + return body + +async def handle_request(body: Dict[str, Any], event: str): + """Process Gitea webhook events""" + action = body.get("action") + if not action: + get_logger().debug("No action found in request body") + return {} + + agent = PRAgent() + + # Handle different event types + if event == "pull_request": + if action in ["opened", "reopened", "synchronized"]: + await handle_pr_event(body, event, action, agent) + elif event == "issue_comment": + if action == "created": + await handle_comment_event(body, event, action, agent) + + return {} + +async def handle_pr_event(body: Dict[str, Any], event: str, action: str, agent: PRAgent): + """Handle pull request events""" + pr = body.get("pull_request", {}) + if not pr: + return + + api_url = pr.get("url") + if not api_url: + return + + # Handle PR based on action + if action in ["opened", "reopened"]: + commands = get_settings().get("gitea.pr_commands", []) + for command in commands: + await agent.handle_request(api_url, command) + elif action == "synchronized": + # Handle push to PR + await agent.handle_request(api_url, "/review --incremental") + +async def handle_comment_event(body: Dict[str, Any], event: str, action: str, agent: PRAgent): + """Handle comment events""" + comment = body.get("comment", {}) + if not comment: + return + + comment_body = comment.get("body", "") + if not comment_body or not comment_body.startswith("/"): + return + + pr_url = body.get("pull_request", {}).get("url") + if not pr_url: + return + + await agent.handle_request(pr_url, comment_body) + +# FastAPI app setup +middleware = [Middleware(RawContextMiddleware)] +app = FastAPI(middleware=middleware) +app.include_router(router) + +def start(): + """Start the Gitea webhook server""" + port = int(os.environ.get("PORT", "3000")) + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=port) + +if __name__ == "__main__": + start() diff --git a/pr_agent/settings/.secrets_template.toml b/pr_agent/settings/.secrets_template.toml index 17c5e8ee..460711cb 100644 --- a/pr_agent/settings/.secrets_template.toml +++ b/pr_agent/settings/.secrets_template.toml @@ -68,6 +68,11 @@ webhook_secret = "" # Optional, may be commented out. personal_access_token = "" shared_secret = "" # webhook secret +[gitea] +# Gitea personal access token +personal_access_token="" +webhook_secret="" # webhook secret + [bitbucket] # For Bitbucket authentication auth_type = "bearer" # "bearer" or "basic" diff --git a/pr_agent/settings/configuration.toml b/pr_agent/settings/configuration.toml index db728ae1..cdb6d5b9 100644 --- a/pr_agent/settings/configuration.toml +++ b/pr_agent/settings/configuration.toml @@ -281,6 +281,15 @@ push_commands = [ "/review", ] +[gitea_app] +url = "https://gitea.com" +handle_push_trigger = false +pr_commands = [ + "/describe", + "/review", + "/improve", +] + [bitbucket_app] pr_commands = [ "/describe --pr_description.final_update_message=false", diff --git a/requirements.txt b/requirements.txt index d1587f25..18f6e383 100644 --- a/requirements.txt +++ b/requirements.txt @@ -31,6 +31,7 @@ gunicorn==22.0.0 pytest-cov==5.0.0 pydantic==2.8.2 html2text==2024.2.26 +giteapy==1.0.8 # Uncomment the following lines to enable the 'similar issue' tool # pinecone-client # pinecone-datasets @ git+https://github.com/mrT23/pinecone-datasets.git@main