diff --git a/docs/docs/core-abilities/incremental_update.md b/docs/docs/core-abilities/incremental_update.md new file mode 100644 index 00000000..3813fb73 --- /dev/null +++ b/docs/docs/core-abilities/incremental_update.md @@ -0,0 +1,33 @@ +# Incremental Update 💎 + +`Supported Git Platforms: GitHub` + +## Overview +The Incremental Update feature helps users focus on feedback for their newest changes, making large PRs more manageable. + +### How it works + +=== "Update Option on Subsequent Commits" + ![code_suggestions_update](https://www.qodo.ai/images/pr_agent/inc_update_before.png){width=512} + +=== "Generation of Incremental Update" + ![code_suggestions_inc_update_result](https://www.qodo.ai/images/pr_agent/inc_update_shown.png){width=512} + +___ + +Whenever new commits are pushed following a recent code suggestions report for this PR, an Update button appears (as seen above). + +Once the user clicks on the button: + +- The `improve` tool identifies the new changes (the "delta") +- Provides suggestions on these recent changes +- Combines these suggestions with the overall PR feedback, prioritizing delta-related comments +- Marks delta-related comments with a textual indication followed by an asterisk (*) with a link to this page, so they can easily be identified + +### Benefits for Developers + +- Focus on what matters: See feedback on newest code first +- Clearer organization: Comments on recent changes are clearly marked +- Better workflow: Address feedback more systematically, starting with recent changes + + diff --git a/docs/docs/core-abilities/index.md b/docs/docs/core-abilities/index.md index 9af26e2e..b97260ee 100644 --- a/docs/docs/core-abilities/index.md +++ b/docs/docs/core-abilities/index.md @@ -8,6 +8,7 @@ Qodo Merge utilizes a variety of core abilities to provide a comprehensive and e - [Dynamic context](https://qodo-merge-docs.qodo.ai/core-abilities/dynamic_context/) - [Fetching ticket context](https://qodo-merge-docs.qodo.ai/core-abilities/fetching_ticket_context/) - [Impact evaluation](https://qodo-merge-docs.qodo.ai/core-abilities/impact_evaluation/) +- [Incremental Update](https://qodo-merge-docs.qodo.ai/core-abilities/incremental_update/) - [Interactivity](https://qodo-merge-docs.qodo.ai/core-abilities/interactivity/) - [Local and global metadata](https://qodo-merge-docs.qodo.ai/core-abilities/metadata/) - [RAG context enrichment](https://qodo-merge-docs.qodo.ai/core-abilities/rag_context_enrichment/) diff --git a/docs/docs/index.md b/docs/docs/index.md index 8dd7c955..79a06e5d 100644 --- a/docs/docs/index.md +++ b/docs/docs/index.md @@ -67,6 +67,7 @@ PR-Agent and Qodo Merge offers extensive pull request functionalities across var | | [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/) | ✅ | | | | +| | [Incremental Update 💎](https://qodo-merge-docs.qodo.ai/core-abilities/incremental_update/) | ✅ | | | | !!! note "💎 means Qodo Merge only" All along the documentation, 💎 marks a feature available only in [Qodo Merge](https://www.codium.ai/pricing/){:target="_blank"}, and not in the open-source version. diff --git a/docs/docs/installation/github.md b/docs/docs/installation/github.md index 018499ee..3eeace4f 100644 --- a/docs/docs/installation/github.md +++ b/docs/docs/installation/github.md @@ -193,9 +193,8 @@ For example: `GITHUB.WEBHOOK_SECRET` --> `GITHUB__WEBHOOK_SECRET` 3. Push image to ECR ```shell - - docker tag codiumai/pr-agent:serverless .dkr.ecr..amazonaws.com/codiumai/pr-agent:serverless - docker push .dkr.ecr..amazonaws.com/codiumai/pr-agent:serverless + docker tag codiumai/pr-agent:serverless .dkr.ecr..amazonaws.com/codiumai/pr-agent:serverless + docker push .dkr.ecr..amazonaws.com/codiumai/pr-agent:serverless ``` 4. Create a lambda function that uses the uploaded image. Set the lambda timeout to be at least 3m. diff --git a/docs/docs/tools/documentation.md b/docs/docs/tools/documentation.md index 247d5d6d..47222f51 100644 --- a/docs/docs/tools/documentation.md +++ b/docs/docs/tools/documentation.md @@ -26,6 +26,29 @@ You can state a name of a specific component in the PR to get documentation only /add_docs component_name ``` +## Manual triggering + +Comment `/add_docs` on a PR to invoke it manually. + +## Automatic triggering + +To automatically run the `add_docs` tool when a pull request is opened, define in a [configuration file](https://qodo-merge-docs.qodo.ai/usage-guide/configuration_options/): + + +```toml +[github_app] +pr_commands = [ + "/add_docs", + ... +] +``` + +The `pr_commands` list defines commands that run automatically when a PR is opened. +Since this is under the [github_app] section, it only applies when using the Qodo Merge GitHub App in GitHub environments. + +!!! note +By default, /add_docs is not triggered automatically. You must explicitly include it in pr_commands to enable this behavior. + ## Configuration options - `docs_style`: The exact style of the documentation (for python docstring). you can choose between: `google`, `numpy`, `sphinx`, `restructuredtext`, `plain`. Default is `sphinx`. diff --git a/docs/docs/tools/review.md b/docs/docs/tools/review.md index e5cc6b93..8d4a5543 100644 --- a/docs/docs/tools/review.md +++ b/docs/docs/tools/review.md @@ -70,6 +70,10 @@ extra_instructions = "..." enable_help_text If set to true, the tool will display a help text in the comment. Default is true. + + num_max_findings + Number of maximum returned findings. Default is 3. + !!! example "Enable\\disable specific sub-sections" @@ -112,7 +116,7 @@ extra_instructions = "..." enable_review_labels_effort - If set to true, the tool will publish a 'Review effort [1-5]: x' label. Default is true. + If set to true, the tool will publish a 'Review effort x/5' label (1–5 scale). Default is true. @@ -141,7 +145,7 @@ extra_instructions = "..." The `review` tool can auto-generate two specific types of labels for a PR: - a `possible security issue` label that detects if a possible [security issue](https://github.com/Codium-ai/pr-agent/blob/tr/user_description/pr_agent/settings/pr_reviewer_prompts.toml#L136) exists in the PR code (`enable_review_labels_security` flag) - - a `Review effort [1-5]: x` label, where x is the estimated effort to review the PR (`enable_review_labels_effort` flag) + - a `Review effort x/5` label, where x is the estimated effort to review the PR on a 1–5 scale (`enable_review_labels_effort` flag) Both modes are useful, and we recommended to enable them. diff --git a/docs/docs/usage-guide/additional_configurations.md b/docs/docs/usage-guide/additional_configurations.md index eb46bbaa..9f9202f6 100644 --- a/docs/docs/usage-guide/additional_configurations.md +++ b/docs/docs/usage-guide/additional_configurations.md @@ -50,7 +50,7 @@ glob = ['*.py'] And to ignore Python files in all PRs using `regex` pattern, set in a configuration file: ``` -[regex] +[ignore] regex = ['.*\.py$'] ``` diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 8525fdee..a25c081b 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -49,6 +49,7 @@ nav: - Dynamic context: 'core-abilities/dynamic_context.md' - Fetching ticket context: 'core-abilities/fetching_ticket_context.md' - Impact evaluation: 'core-abilities/impact_evaluation.md' + - Incremental Update: 'core-abilities/incremental_update.md' - Interactivity: 'core-abilities/interactivity.md' - Local and global metadata: 'core-abilities/metadata.md' - RAG context enrichment: 'core-abilities/rag_context_enrichment.md' diff --git a/pr_agent/algo/ai_handlers/litellm_ai_handler.py b/pr_agent/algo/ai_handlers/litellm_ai_handler.py index 88041658..8d727b8b 100644 --- a/pr_agent/algo/ai_handlers/litellm_ai_handler.py +++ b/pr_agent/algo/ai_handlers/litellm_ai_handler.py @@ -371,12 +371,12 @@ class LiteLLMAIHandler(BaseAiHandler): get_logger().info(f"\nUser prompt:\n{user}") response = await acompletion(**kwargs) - except (openai.APIError, openai.APITimeoutError) as e: - get_logger().warning(f"Error during LLM inference: {e}") - raise except (openai.RateLimitError) as e: get_logger().error(f"Rate limit error during LLM inference: {e}") raise + except (openai.APIError, openai.APITimeoutError) as e: + get_logger().warning(f"Error during LLM inference: {e}") + raise except (Exception) as e: get_logger().warning(f"Unknown error during LLM inference: {e}") raise openai.APIError from e diff --git a/pr_agent/git_providers/__init__.py b/pr_agent/git_providers/__init__.py index 16547d90..51c6f624 100644 --- a/pr_agent/git_providers/__init__.py +++ b/pr_agent/git_providers/__init__.py @@ -11,6 +11,7 @@ from pr_agent.git_providers.git_provider import GitProvider 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 +from pr_agent.git_providers.gitea_provider import GiteaProvider _GIT_PROVIDERS = { 'github': GithubProvider, @@ -21,6 +22,7 @@ _GIT_PROVIDERS = { 'codecommit': CodeCommitProvider, 'local': LocalGitProvider, 'gerrit': GerritProvider, + 'gitea': GiteaProvider, } diff --git a/pr_agent/git_providers/azuredevops_provider.py b/pr_agent/git_providers/azuredevops_provider.py index 35165bdd..d71a029f 100644 --- a/pr_agent/git_providers/azuredevops_provider.py +++ b/pr_agent/git_providers/azuredevops_provider.py @@ -618,7 +618,7 @@ class AzureDevopsProvider(GitProvider): return pr_id except Exception as e: if get_settings().config.verbosity_level >= 2: - get_logger().info(f"Failed to get pr id, error: {e}") + get_logger().info(f"Failed to get PR id, error: {e}") return "" def publish_file_comments(self, file_comments: list) -> bool: diff --git a/pr_agent/git_providers/gitea_provider.py b/pr_agent/git_providers/gitea_provider.py new file mode 100644 index 00000000..1d671558 --- /dev/null +++ b/pr_agent/git_providers/gitea_provider.py @@ -0,0 +1,258 @@ +from typing import Optional, Tuple, List, Dict +from urllib.parse import urlparse +import requests +from pr_agent.git_providers.git_provider import GitProvider +from pr_agent.config_loader import get_settings +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, 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' + } + self.owner = None + self.repo = None + self.pr_num = None + self.pr = None + self.pr_url = pr_url + self.incremental = incremental + if pr_url: + self.set_pr(pr_url) + + @staticmethod + def _parse_pr_url(pr_url: str) -> Tuple[str, str, str]: + """ + Parse Gitea PR URL to (owner, repo, pr_number) + """ + parsed_url = urlparse(pr_url) + 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] + + 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() + + 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() + + def is_supported(self, capability: str) -> bool: + # Gitea/Forgejo supports most capabilities + return True + + 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 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() + + 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 + + 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 + + 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() + + 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() + + 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" + + 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) + }] + } + response = requests.post(url, headers=self.headers, json=data) + response.raise_for_status() + + 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 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 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()] + + 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 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() + + 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: + 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" + except Exception as e: + get_logger().exception(f"Failed to get git repo URL from: {issues_or_pr_url}") + 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}") + + repo_name = path_parts[1] + if repo_name.endswith('.git'): + repo_name = repo_name[:-4] + + prefix = f"{parsed_url.scheme}://{parsed_url.netloc}/{path_parts[0]}/{repo_name}/src/branch/{desired_branch}" + suffix = "" + return prefix, suffix + except Exception as e: + get_logger().exception(f"Failed to get canonical URL parts from: {repo_git_url}") + 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_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() diff --git a/pr_agent/git_providers/github_provider.py b/pr_agent/git_providers/github_provider.py index 4f3a5ec7..fa52b7dc 100644 --- a/pr_agent/git_providers/github_provider.py +++ b/pr_agent/git_providers/github_provider.py @@ -96,7 +96,7 @@ class GithubProvider(GitProvider): parsed_url = urlparse(given_url) repo_path = (parsed_url.path.split('.git')[0])[1:] # //.git -> / if not repo_path: - get_logger().error(f"url is neither an issues url nor a pr url nor a valid git url: {given_url}. Returning empty result.") + get_logger().error(f"url is neither an issues url nor a PR url nor a valid git url: {given_url}. Returning empty result.") return "" return repo_path except Exception as e: diff --git a/pr_agent/settings/configuration.toml b/pr_agent/settings/configuration.toml index ce097c95..c6437931 100644 --- a/pr_agent/settings/configuration.toml +++ b/pr_agent/settings/configuration.toml @@ -81,6 +81,7 @@ require_ticket_analysis_review=true # general options persistent_comment=true extra_instructions = "" +num_max_findings = 3 final_update_message = true # review labels enable_review_labels_security=true diff --git a/pr_agent/settings/pr_reviewer_prompts.toml b/pr_agent/settings/pr_reviewer_prompts.toml index a322b2b9..d4c0a523 100644 --- a/pr_agent/settings/pr_reviewer_prompts.toml +++ b/pr_agent/settings/pr_reviewer_prompts.toml @@ -98,7 +98,7 @@ class Review(BaseModel): {%- if question_str %} insights_from_user_answers: str = Field(description="shortly summarize the insights you gained from the user's answers to the questions") {%- endif %} - key_issues_to_review: List[KeyIssuesComponentLink] = Field("A short and diverse list (0-3 issues) of high-priority bugs, problems or performance concerns introduced in the PR code, which the PR reviewer should further focus on and validate during the review process.") + key_issues_to_review: List[KeyIssuesComponentLink] = Field("A short and diverse list (0-{{ num_max_findings }} issues) of high-priority bugs, problems or performance concerns introduced in the PR code, which the PR reviewer should further focus on and validate during the review process.") {%- if require_security_review %} security_concerns: str = Field(description="Does this PR code introduce possible vulnerabilities such as exposure of sensitive information (e.g., API keys, secrets, passwords), or security concerns like SQL injection, XSS, CSRF, and others ? Answer 'No' (without explaining why) if there are no possible issues. If there are security concerns or issues, start your answer with a short header, such as: 'Sensitive information exposure: ...', 'SQL injection: ...' etc. Explain your answer. Be specific and give examples if possible") {%- endif %} diff --git a/pr_agent/tools/pr_description.py b/pr_agent/tools/pr_description.py index 6ab13ee1..df82db67 100644 --- a/pr_agent/tools/pr_description.py +++ b/pr_agent/tools/pr_description.py @@ -199,7 +199,7 @@ class PRDescription: async def _prepare_prediction(self, model: str) -> None: if get_settings().pr_description.use_description_markers and 'pr_agent:' not in self.user_description: - get_logger().info("Markers were enabled, but user description does not contain markers. skipping AI prediction") + get_logger().info("Markers were enabled, but user description does not contain markers. Skipping AI prediction") return None large_pr_handling = get_settings().pr_description.enable_large_pr_handling and "pr_description_only_files_prompts" in get_settings() @@ -707,7 +707,7 @@ class PRDescription: pr_body += """""" except Exception as e: - get_logger().error(f"Error processing pr files to markdown {self.pr_id}: {str(e)}") + get_logger().error(f"Error processing PR files to markdown {self.pr_id}: {str(e)}") pass return pr_body, pr_comments diff --git a/pr_agent/tools/pr_reviewer.py b/pr_agent/tools/pr_reviewer.py index ff48819f..714ee867 100644 --- a/pr_agent/tools/pr_reviewer.py +++ b/pr_agent/tools/pr_reviewer.py @@ -81,6 +81,7 @@ class PRReviewer: "language": self.main_language, "diff": "", # empty diff for initial calculation "num_pr_files": self.git_provider.get_num_of_files(), + "num_max_findings": get_settings().pr_reviewer.num_max_findings, "require_score": get_settings().pr_reviewer.require_score_review, "require_tests": get_settings().pr_reviewer.require_tests_review, "require_estimate_effort_to_review": get_settings().pr_reviewer.require_estimate_effort_to_review, @@ -316,7 +317,9 @@ class PRReviewer: get_logger().exception(f"Failed to remove previous review comment, error: {e}") def _can_run_incremental_review(self) -> bool: - """Checks if we can run incremental review according the various configurations and previous review""" + """ + Checks if we can run incremental review according the various configurations and previous review. + """ # checking if running is auto mode but there are no new commits if self.is_auto and not self.incremental.first_new_commit_sha: get_logger().info(f"Incremental review is enabled for {self.pr_url} but there are no new commits") diff --git a/tests/e2e_tests/test_gitea_app.py b/tests/e2e_tests/test_gitea_app.py new file mode 100644 index 00000000..3a209975 --- /dev/null +++ b/tests/e2e_tests/test_gitea_app.py @@ -0,0 +1,185 @@ +import os +import time +import requests +from datetime import datetime + +from pr_agent.config_loader import get_settings +from pr_agent.log import get_logger, setup_logger +from tests.e2e_tests.e2e_utils import (FILE_PATH, + IMPROVE_START_WITH_REGEX_PATTERN, + NEW_FILE_CONTENT, NUM_MINUTES, + PR_HEADER_START_WITH, REVIEW_START_WITH) + +log_level = os.environ.get("LOG_LEVEL", "INFO") +setup_logger(log_level) +logger = get_logger() + +def test_e2e_run_gitea_app(): + repo_name = 'pr-agent-tests' + owner = 'codiumai' + base_branch = "main" + new_branch = f"gitea_app_e2e_test-{datetime.now().strftime('%Y-%m-%d-%H-%M-%S')}" + get_settings().config.git_provider = "gitea" + + headers = None + pr_number = None + + try: + gitea_url = get_settings().get("GITEA.URL", None) + gitea_token = get_settings().get("GITEA.TOKEN", None) + + if not gitea_url: + logger.error("GITEA.URL is not set in the configuration") + logger.info("Please set GITEA.URL in .env file or environment variables") + assert False, "GITEA.URL is not set in the configuration" + + if not gitea_token: + logger.error("GITEA.TOKEN is not set in the configuration") + logger.info("Please set GITEA.TOKEN in .env file or environment variables") + assert False, "GITEA.TOKEN is not set in the configuration" + + headers = { + 'Authorization': f'token {gitea_token}', + 'Content-Type': 'application/json', + 'Accept': 'application/json' + } + + logger.info(f"Creating a new branch {new_branch} from {base_branch}") + + response = requests.get( + f"{gitea_url}/api/v1/repos/{owner}/{repo_name}/branches/{base_branch}", + headers=headers + ) + response.raise_for_status() + base_branch_data = response.json() + base_commit_sha = base_branch_data['commit']['id'] + + branch_data = { + 'ref': f"refs/heads/{new_branch}", + 'sha': base_commit_sha + } + response = requests.post( + f"{gitea_url}/api/v1/repos/{owner}/{repo_name}/git/refs", + headers=headers, + json=branch_data + ) + response.raise_for_status() + + logger.info(f"Updating file {FILE_PATH} in branch {new_branch}") + + import base64 + file_content_encoded = base64.b64encode(NEW_FILE_CONTENT.encode()).decode() + + try: + response = requests.get( + f"{gitea_url}/api/v1/repos/{owner}/{repo_name}/contents/{FILE_PATH}?ref={new_branch}", + headers=headers + ) + response.raise_for_status() + existing_file = response.json() + file_sha = existing_file.get('sha') + + file_data = { + 'message': 'Update cli_pip.py', + 'content': file_content_encoded, + 'sha': file_sha, + 'branch': new_branch + } + except: + file_data = { + 'message': 'Add cli_pip.py', + 'content': file_content_encoded, + 'branch': new_branch + } + + response = requests.put( + f"{gitea_url}/api/v1/repos/{owner}/{repo_name}/contents/{FILE_PATH}", + headers=headers, + json=file_data + ) + response.raise_for_status() + + logger.info(f"Creating a pull request from {new_branch} to {base_branch}") + pr_data = { + 'title': f'Test PR from {new_branch}', + 'body': 'update cli_pip.py', + 'head': new_branch, + 'base': base_branch + } + response = requests.post( + f"{gitea_url}/api/v1/repos/{owner}/{repo_name}/pulls", + headers=headers, + json=pr_data + ) + response.raise_for_status() + pr = response.json() + pr_number = pr['number'] + + for i in range(NUM_MINUTES): + logger.info(f"Waiting for the PR to get all the tool results...") + time.sleep(60) + + response = requests.get( + f"{gitea_url}/api/v1/repos/{owner}/{repo_name}/issues/{pr_number}/comments", + headers=headers + ) + response.raise_for_status() + comments = response.json() + + if len(comments) >= 5: + valid_review = False + for comment in comments: + if comment['body'].startswith('## PR Reviewer Guide 🔍'): + valid_review = True + break + if valid_review: + break + else: + logger.error("REVIEW feedback is invalid") + raise Exception("REVIEW feedback is invalid") + else: + logger.info(f"Waiting for the PR to get all the tool results. {i + 1} minute(s) passed") + else: + assert False, f"After {NUM_MINUTES} minutes, the PR did not get all the tool results" + + logger.info(f"Cleaning up: closing PR and deleting branch {new_branch}") + + close_data = {'state': 'closed'} + response = requests.patch( + f"{gitea_url}/api/v1/repos/{owner}/{repo_name}/pulls/{pr_number}", + headers=headers, + json=close_data + ) + response.raise_for_status() + + response = requests.delete( + f"{gitea_url}/api/v1/repos/{owner}/{repo_name}/git/refs/heads/{new_branch}", + headers=headers + ) + response.raise_for_status() + + logger.info(f"Succeeded in running e2e test for Gitea app on the PR") + except Exception as e: + logger.error(f"Failed to run e2e test for Gitea app: {e}") + raise + finally: + try: + if headers is None or gitea_url is None: + return + + if pr_number is not None: + requests.patch( + f"{gitea_url}/api/v1/repos/{owner}/{repo_name}/pulls/{pr_number}", + headers=headers, + json={'state': 'closed'} + ) + + requests.delete( + f"{gitea_url}/api/v1/repos/{owner}/{repo_name}/git/refs/heads/{new_branch}", + headers=headers + ) + except Exception as cleanup_error: + logger.error(f"Failed to clean up after test: {cleanup_error}") + +if __name__ == '__main__': + test_e2e_run_gitea_app() \ No newline at end of file diff --git a/tests/unittest/test_gitea_provider.py b/tests/unittest/test_gitea_provider.py new file mode 100644 index 00000000..d88de0e0 --- /dev/null +++ b/tests/unittest/test_gitea_provider.py @@ -0,0 +1,126 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from pr_agent.algo.types import EDIT_TYPE +from pr_agent.git_providers.gitea_provider import GiteaProvider + + +class TestGiteaProvider: + """Unit-tests for GiteaProvider following project style (explicit object construction, minimal patching).""" + + def _provider(self): + """Create provider instance with patched settings and avoid real HTTP calls.""" + with patch('pr_agent.git_providers.gitea_provider.get_settings') as mock_get_settings, \ + patch('requests.get') as mock_get: + settings = MagicMock() + settings.get.side_effect = lambda k, d=None: { + 'GITEA.URL': 'https://gitea.example.com', + 'GITEA.TOKEN': 'test-token' + }.get(k, d) + mock_get_settings.return_value = settings + # Stub the PR fetch triggered during provider initialization + pr_resp = MagicMock() + pr_resp.json.return_value = { + 'title': 'stub', + 'body': 'stub', + 'head': {'ref': 'main'}, + 'user': {'id': 1} + } + pr_resp.raise_for_status = MagicMock() + mock_get.return_value = pr_resp + return GiteaProvider('https://gitea.example.com/owner/repo/pulls/123') + + # ---------------- URL parsing ---------------- + def test_parse_pr_url_valid(self): + owner, repo, pr_num = GiteaProvider._parse_pr_url('https://gitea.example.com/owner/repo/pulls/123') + assert (owner, repo, pr_num) == ('owner', 'repo', '123') + + def test_parse_pr_url_invalid(self): + with pytest.raises(ValueError): + GiteaProvider._parse_pr_url('https://gitea.example.com/owner/repo') + + # ---------------- simple getters ---------------- + def test_get_files(self): + provider = self._provider() + mock_resp = MagicMock() + mock_resp.json.return_value = [{'filename': 'a.txt'}, {'filename': 'b.txt'}] + mock_resp.raise_for_status = MagicMock() + with patch('requests.get', return_value=mock_resp) as mock_get: + assert provider.get_files() == ['a.txt', 'b.txt'] + mock_get.assert_called_once() + + def test_get_diff_files(self): + provider = self._provider() + mock_resp = MagicMock() + mock_resp.json.return_value = [ + {'filename': 'f1', 'previous_filename': 'old_f1', 'status': 'renamed', 'patch': ''}, + {'filename': 'f2', 'status': 'added', 'patch': ''}, + {'filename': 'f3', 'status': 'deleted', 'patch': ''}, + {'filename': 'f4', 'status': 'modified', 'patch': ''} + ] + mock_resp.raise_for_status = MagicMock() + with patch('requests.get', return_value=mock_resp): + res = provider.get_diff_files() + assert [f.edit_type for f in res] == [EDIT_TYPE.RENAMED, EDIT_TYPE.ADDED, EDIT_TYPE.DELETED, + EDIT_TYPE.MODIFIED] + + # ---------------- publishing methods ---------------- + def test_publish_description(self): + provider = self._provider() + mock_resp = MagicMock(); + mock_resp.raise_for_status = MagicMock() + with patch('requests.patch', return_value=mock_resp) as mock_patch: + provider.publish_description('t', 'b'); + mock_patch.assert_called_once() + + def test_publish_comment(self): + provider = self._provider() + mock_resp = MagicMock(); + mock_resp.raise_for_status = MagicMock() + with patch('requests.post', return_value=mock_resp) as mock_post: + provider.publish_comment('c'); + mock_post.assert_called_once() + + def test_publish_inline_comment(self): + provider = self._provider() + mock_resp = MagicMock(); + mock_resp.raise_for_status = MagicMock() + with patch('requests.post', return_value=mock_resp) as mock_post: + provider.publish_inline_comment('body', 'file', '10'); + mock_post.assert_called_once() + + # ---------------- labels & reactions ---------------- + def test_get_pr_labels(self): + provider = self._provider() + mock_resp = MagicMock(); + mock_resp.raise_for_status = MagicMock(); + mock_resp.json.return_value = [{'name': 'l1'}] + with patch('requests.get', return_value=mock_resp): + assert provider.get_pr_labels() == ['l1'] + + def test_add_eyes_reaction(self): + provider = self._provider() + mock_resp = MagicMock(); + mock_resp.raise_for_status = MagicMock(); + mock_resp.json.return_value = {'id': 7} + with patch('requests.post', return_value=mock_resp): + assert provider.add_eyes_reaction(1) == 7 + + # ---------------- commit messages & url helpers ---------------- + def test_get_commit_messages(self): + provider = self._provider() + mock_resp = MagicMock(); + mock_resp.raise_for_status = MagicMock() + mock_resp.json.return_value = [ + {'commit': {'message': 'm1'}}, {'commit': {'message': 'm2'}}] + with patch('requests.get', return_value=mock_resp): + assert provider.get_commit_messages() == ['m1', 'm2'] + + def test_git_url_helpers(self): + provider = self._provider() + issues_url = 'https://gitea.example.com/owner/repo/pulls/3' + assert provider.get_git_repo_url(issues_url) == 'https://gitea.example.com/owner/repo.git' + prefix, suffix = provider.get_canonical_url_parts('https://gitea.example.com/owner/repo.git', 'dev') + assert prefix == 'https://gitea.example.com/owner/repo/src/branch/dev' + assert suffix == ''