From 9ef0c451bf684444472c5ad4d780c0372be29f4a Mon Sep 17 00:00:00 2001 From: Pinyoo Thotaboot Date: Fri, 16 May 2025 16:30:10 +0700 Subject: [PATCH 01/22] Create provider module for --- pr_agent/git_providers/gitea_provider.py | 982 +++++++++++++++++++++++ 1 file changed, 982 insertions(+) create mode 100644 pr_agent/git_providers/gitea_provider.py diff --git a/pr_agent/git_providers/gitea_provider.py b/pr_agent/git_providers/gitea_provider.py new file mode 100644 index 00000000..9f271556 --- /dev/null +++ b/pr_agent/git_providers/gitea_provider.py @@ -0,0 +1,982 @@ +import hashlib +import json +from typing import Any, Dict, List, Optional, Set, Tuple +from urllib.parse import urlparse + +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 + + +class GiteaProvider(GitProvider): + def __init__(self, url: Optional[str] = None): + super().__init__() + self.logger = get_logger() + + 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_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.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() + + 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: + 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)}") + + 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("The provided URL does not appear to be a Gitea PR URL") + + try: + pr_number = int(path_parts[3]) + except ValueError as e: + raise ValueError("Unable to convert PR number to integer") from e + + owner = path_parts[0] + repo = path_parts[1] + + return owner, repo, pr_number + + def _parse_issue_url(self, issue_url: str) -> Tuple[str, str, int]: + parsed_url = urlparse(issue_url) + + if parsed_url.path.startswith('/api/v1'): + parsed_url = urlparse(issue_url.replace("/api/v1", "")) + + 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") + + try: + issue_number = int(path_parts[3]) + except ValueError as e: + raise ValueError("Unable to convert issue number to integer") from e + + owner = path_parts[0] + repo = path_parts[1] + + return owner, repo, issue_number + + 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)}") + + 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) + reponse = self.repo_api.create_comment( + owner=self.owner, + repo=self.repo, + index=index, + comment=comment + ) + + if not reponse: + self.logger.error("Failed to publish comment") + return None + + if is_temporary: + self.temp_comments.append(comment) + + self.comments_list.append({ + "is_temporary": is_temporary, + "comment": comment, + "comment_id": reponse.id if isinstance(reponse, tuple) else reponse.id + }) + self.logger.info("Comment published") + + 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_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" + + 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 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 + ) + + 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]: + """Add eyes reaction to a comment""" + try: + 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: + 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 "" + + try: + commit_messages = [commit["commit"]["message"] for commit in pr_commits if commit] + + if not commit_messages: + self.logger.error("No commit messages found") + return "" + + commit_message = "".join(commit_messages) + if max_tokens: + commit_message = clip_tokens(commit_message, max_tokens) + + return commit_message + except Exception as e: + self.logger.error(f"Error processing commit messages: {str(e)}") + return "" + + 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 "" + + 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 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: + 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: + 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: + self.logger.error("Unexpected response format") + return "" + + 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')) + + return {} + + 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 [] From cf2b95b7663346fa0492d6d43512b32b4db1488c Mon Sep 17 00:00:00 2001 From: Pinyoo Thotaboot Date: Fri, 16 May 2025 16:30:50 +0700 Subject: [PATCH 02/22] Create webhook server implement for --- pr_agent/servers/gitea_app.py | 120 ++++++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 pr_agent/servers/gitea_app.py diff --git a/pr_agent/servers/gitea_app.py b/pr_agent/servers/gitea_app.py new file mode 100644 index 00000000..4df8b84c --- /dev/null +++ b/pr_agent/servers/gitea_app.py @@ -0,0 +1,120 @@ +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) + verify_signature(body_bytes, webhook_secret, f"sha256={signature_header}") + + 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() From 2d7636543c5981bdbbe85112390c568f64719886 Mon Sep 17 00:00:00 2001 From: Pinyoo Thotaboot Date: Fri, 16 May 2025 16:31:49 +0700 Subject: [PATCH 03/22] Implement provider --- pr_agent/algo/file_filter.py | 3 +++ pr_agent/git_providers/__init__.py | 2 ++ 2 files changed, 5 insertions(+) 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 16547d90..8ee2db08 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 @@ -21,6 +22,7 @@ _GIT_PROVIDERS = { 'codecommit': CodeCommitProvider, 'local': LocalGitProvider, 'gerrit': GerritProvider, + 'gitea': GiteaProvider } From fab8573c4d81530fca33507d5ddc53d617a665a7 Mon Sep 17 00:00:00 2001 From: Pinyoo Thotaboot Date: Fri, 16 May 2025 16:33:36 +0700 Subject: [PATCH 04/22] Set default configuration --- pr_agent/settings/configuration.toml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pr_agent/settings/configuration.toml b/pr_agent/settings/configuration.toml index e63b7ea8..50dcbdb9 100644 --- a/pr_agent/settings/configuration.toml +++ b/pr_agent/settings/configuration.toml @@ -277,6 +277,14 @@ push_commands = [ "/review", ] +[gitea_app] +handle_push_trigger = true +pr_commands = [ + "/describe --pr_description.final_update_message=false", + "/review", + "/improve", +] + [bitbucket_app] pr_commands = [ "/describe --pr_description.final_update_message=false", From a692a700274fda7ca6b4f84e63f7b33ffbbe5aff Mon Sep 17 00:00:00 2001 From: Pinyoo Thotaboot Date: Fri, 16 May 2025 16:34:11 +0700 Subject: [PATCH 05/22] Implement for docker --- docker/Dockerfile | 5 +++++ 1 file changed, 5 insertions(+) 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 From 8b1abbcc2c41004fca988b3872e73985695de256 Mon Sep 17 00:00:00 2001 From: Pinyoo Thotaboot Date: Fri, 16 May 2025 16:34:53 +0700 Subject: [PATCH 06/22] Add lib dependency --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index 86a50c84..1038af69 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 From 1b74942919f63ab8cafec68a3b17470426650bcf Mon Sep 17 00:00:00 2001 From: Pinyoo Thotaboot Date: Tue, 20 May 2025 15:18:07 +0700 Subject: [PATCH 07/22] Set default configuration of Gitea --- pr_agent/settings/.secrets_template.toml | 5 +++++ pr_agent/settings/configuration.toml | 1 + 2 files changed, 6 insertions(+) diff --git a/pr_agent/settings/.secrets_template.toml b/pr_agent/settings/.secrets_template.toml index 6572677d..9590a84c 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 50dcbdb9..421ecff4 100644 --- a/pr_agent/settings/configuration.toml +++ b/pr_agent/settings/configuration.toml @@ -278,6 +278,7 @@ push_commands = [ ] [gitea_app] +url = "https://gitea.com" handle_push_trigger = true pr_commands = [ "/describe --pr_description.final_update_message=false", From 2d619564f259fdacc7d51775c1cd3862b113c4cd Mon Sep 17 00:00:00 2001 From: Pinyoo Thotaboot Date: Tue, 20 May 2025 15:51:50 +0700 Subject: [PATCH 08/22] Update README for Gitea --- README.md | 82 +++++++++++++++++++++++++++---------------------------- 1 file changed, 41 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index c18bfa7c..67fa57a7 100644 --- a/README.md +++ b/README.md @@ -91,47 +91,47 @@ This version includes a new tool, [Help Docs](https://qodo-merge-docs.qodo.ai/to 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)) From bd68a0de559611bfd37dc51653f6f3d9921aeb3b Mon Sep 17 00:00:00 2001 From: Pinyoo Thotaboot Date: Tue, 20 May 2025 16:46:32 +0700 Subject: [PATCH 09/22] Update Gitea documents --- docs/docs/installation/gitea.md | 46 +++++++++++++++++++ docs/docs/installation/index.md | 1 + docs/docs/installation/locally.md | 15 +++++- docs/docs/installation/pr_agent.md | 8 ++++ .../docs/usage-guide/automations_and_usage.md | 15 +++++- docs/docs/usage-guide/index.md | 1 + docs/docs/usage-guide/introduction.md | 2 +- 7 files changed, 85 insertions(+), 3 deletions(-) create mode 100644 docs/docs/installation/gitea.md 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/installation/pr_agent.md b/docs/docs/installation/pr_agent.md index 1982b7a1..9a0e3f29 100644 --- a/docs/docs/installation/pr_agent.md +++ b/docs/docs/installation/pr_agent.md @@ -47,3 +47,11 @@ Configure PR-Agent with Azure DevOps as: - Local Azure DevOps webhook [View Azure DevOps Integration Guide →](https://qodo-merge-docs.qodo.ai/installation/azure/) + +## 🔷 Gitea Integration + +Deploy PR-Agent on Gitea as: + +- Local Gitea webhook server + +[View Gitea Integration Guide →](https://qodo-merge-docs.qodo.ai/installation/gitea/) 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/docs/docs/usage-guide/introduction.md b/docs/docs/usage-guide/introduction.md index 11e56b32..74838c1c 100644 --- a/docs/docs/usage-guide/introduction.md +++ b/docs/docs/usage-guide/introduction.md @@ -7,5 +7,5 @@ After [installation](https://qodo-merge-docs.qodo.ai/installation/), there are t Specifically, CLI commands can be issued by invoking a pre-built [docker image](https://qodo-merge-docs.qodo.ai/installation/locally/#using-docker-image), or by invoking a [locally cloned repo](https://qodo-merge-docs.qodo.ai/installation/locally/#run-from-source). -For online usage, you will need to setup either a [GitHub App](https://qodo-merge-docs.qodo.ai/installation/github/#run-as-a-github-app) or a [GitHub Action](https://qodo-merge-docs.qodo.ai/installation/github/#run-as-a-github-action) (GitHub), a [GitLab webhook](https://qodo-merge-docs.qodo.ai/installation/gitlab/#run-a-gitlab-webhook-server) (GitLab), or a [BitBucket App](https://qodo-merge-docs.qodo.ai/installation/bitbucket/#run-using-codiumai-hosted-bitbucket-app) (BitBucket). +For online usage, you will need to setup either a [GitHub App](https://qodo-merge-docs.qodo.ai/installation/github/#run-as-a-github-app) or a [GitHub Action](https://qodo-merge-docs.qodo.ai/installation/github/#run-as-a-github-action) (GitHub), a [GitLab webhook](https://qodo-merge-docs.qodo.ai/installation/gitlab/#run-a-gitlab-webhook-server) (GitLab), or a [BitBucket App](https://qodo-merge-docs.qodo.ai/installation/bitbucket/#run-using-codiumai-hosted-bitbucket-app) (BitBucket) or a [Gitea webhook](https://qodo-merge-docs.qodo.ai/installation/gitea/#run-a-gitea-webhook-server) (Gitea). These platforms also enable to run Qodo Merge specific tools automatically when a new PR is opened, or on each push to a branch. From b686a707a43bb8f1cdc2618616062c4ca898137e Mon Sep 17 00:00:00 2001 From: Pinyoo Thotaboot Date: Tue, 20 May 2025 16:54:20 +0700 Subject: [PATCH 10/22] Not implement online --- docs/docs/installation/pr_agent.md | 8 -------- docs/docs/usage-guide/introduction.md | 2 +- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/docs/docs/installation/pr_agent.md b/docs/docs/installation/pr_agent.md index 9a0e3f29..1982b7a1 100644 --- a/docs/docs/installation/pr_agent.md +++ b/docs/docs/installation/pr_agent.md @@ -47,11 +47,3 @@ Configure PR-Agent with Azure DevOps as: - Local Azure DevOps webhook [View Azure DevOps Integration Guide →](https://qodo-merge-docs.qodo.ai/installation/azure/) - -## 🔷 Gitea Integration - -Deploy PR-Agent on Gitea as: - -- Local Gitea webhook server - -[View Gitea Integration Guide →](https://qodo-merge-docs.qodo.ai/installation/gitea/) diff --git a/docs/docs/usage-guide/introduction.md b/docs/docs/usage-guide/introduction.md index 74838c1c..11e56b32 100644 --- a/docs/docs/usage-guide/introduction.md +++ b/docs/docs/usage-guide/introduction.md @@ -7,5 +7,5 @@ After [installation](https://qodo-merge-docs.qodo.ai/installation/), there are t Specifically, CLI commands can be issued by invoking a pre-built [docker image](https://qodo-merge-docs.qodo.ai/installation/locally/#using-docker-image), or by invoking a [locally cloned repo](https://qodo-merge-docs.qodo.ai/installation/locally/#run-from-source). -For online usage, you will need to setup either a [GitHub App](https://qodo-merge-docs.qodo.ai/installation/github/#run-as-a-github-app) or a [GitHub Action](https://qodo-merge-docs.qodo.ai/installation/github/#run-as-a-github-action) (GitHub), a [GitLab webhook](https://qodo-merge-docs.qodo.ai/installation/gitlab/#run-a-gitlab-webhook-server) (GitLab), or a [BitBucket App](https://qodo-merge-docs.qodo.ai/installation/bitbucket/#run-using-codiumai-hosted-bitbucket-app) (BitBucket) or a [Gitea webhook](https://qodo-merge-docs.qodo.ai/installation/gitea/#run-a-gitea-webhook-server) (Gitea). +For online usage, you will need to setup either a [GitHub App](https://qodo-merge-docs.qodo.ai/installation/github/#run-as-a-github-app) or a [GitHub Action](https://qodo-merge-docs.qodo.ai/installation/github/#run-as-a-github-action) (GitHub), a [GitLab webhook](https://qodo-merge-docs.qodo.ai/installation/gitlab/#run-a-gitlab-webhook-server) (GitLab), or a [BitBucket App](https://qodo-merge-docs.qodo.ai/installation/bitbucket/#run-using-codiumai-hosted-bitbucket-app) (BitBucket). These platforms also enable to run Qodo Merge specific tools automatically when a new PR is opened, or on each push to a branch. From 48c29c9ffa0c9329b56568ecbf45044eb6ad576b Mon Sep 17 00:00:00 2001 From: Pinyoo Thotaboot Date: Thu, 22 May 2025 14:59:29 +0700 Subject: [PATCH 11/22] Add null check --- pr_agent/git_providers/gitea_provider.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pr_agent/git_providers/gitea_provider.py b/pr_agent/git_providers/gitea_provider.py index 9f271556..5eef4362 100644 --- a/pr_agent/git_providers/gitea_provider.py +++ b/pr_agent/git_providers/gitea_provider.py @@ -542,6 +542,10 @@ class GiteaProvider(GitProvider): 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 "" From 000f0ba93ebd123338fc7ec67387839cbefbbb05 Mon Sep 17 00:00:00 2001 From: Pinyoo Thotaboot Date: Thu, 22 May 2025 15:01:08 +0700 Subject: [PATCH 12/22] Fixed ensure SHA --- pr_agent/git_providers/gitea_provider.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pr_agent/git_providers/gitea_provider.py b/pr_agent/git_providers/gitea_provider.py index 5eef4362..91100cab 100644 --- a/pr_agent/git_providers/gitea_provider.py +++ b/pr_agent/git_providers/gitea_provider.py @@ -102,7 +102,7 @@ class GiteaProvider(GitProvider): if not is_valid_file(file_path): continue - if file_path: + if file_path and self.sha: try: content = self.repo_api.get_file_content( owner=self.owner, From 0f893bc4926cf0296c7e29c7153b67ecb321346c Mon Sep 17 00:00:00 2001 From: Pinyoo Thotaboot Date: Thu, 22 May 2025 15:03:15 +0700 Subject: [PATCH 13/22] Fixed webhook security concern --- pr_agent/servers/gitea_app.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pr_agent/servers/gitea_app.py b/pr_agent/servers/gitea_app.py index 4df8b84c..85399f2c 100644 --- a/pr_agent/servers/gitea_app.py +++ b/pr_agent/servers/gitea_app.py @@ -47,6 +47,10 @@ async def get_body(request: Request): 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") + verify_signature(body_bytes, webhook_secret, f"sha256={signature_header}") return body From 162cc9d833612085da746f3fa94631187fee6fb2 Mon Sep 17 00:00:00 2001 From: Pinyoo Thotaboot Date: Thu, 22 May 2025 15:06:35 +0700 Subject: [PATCH 14/22] Fixed error propagation --- pr_agent/git_providers/gitea_provider.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pr_agent/git_providers/gitea_provider.py b/pr_agent/git_providers/gitea_provider.py index 91100cab..4ac2e63b 100644 --- a/pr_agent/git_providers/gitea_provider.py +++ b/pr_agent/git_providers/gitea_provider.py @@ -757,8 +757,9 @@ class RepoApi(giteapy.RepositoryApi): raw_data = response[0].read() return raw_data.decode('utf-8') else: - self.logger.error("Unexpected response format") - return "" + error_msg = f"Unexpected response format received from API: {type(response)}" + self.logger.error(error_msg) + return RuntimeError(error_msg) except ApiException as e: self.logger.error(f"Error getting diff: {str(e)}") From f78762cf2e93c69fa26a3ac6a8c2439a7d925412 Mon Sep 17 00:00:00 2001 From: Pinyoo Thotaboot Date: Mon, 26 May 2025 11:04:11 +0700 Subject: [PATCH 15/22] Change the default value of is --- pr_agent/settings/configuration.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pr_agent/settings/configuration.toml b/pr_agent/settings/configuration.toml index 9c80e9fb..7ef6c4f2 100644 --- a/pr_agent/settings/configuration.toml +++ b/pr_agent/settings/configuration.toml @@ -283,7 +283,7 @@ push_commands = [ [gitea_app] url = "https://gitea.com" -handle_push_trigger = true +handle_push_trigger = false pr_commands = [ "/describe --pr_description.final_update_message=false", "/review", From 5e9c56b96c5f0ce3e31a6679de2a88d156867a3c Mon Sep 17 00:00:00 2001 From: Pinyoo Thotaboot Date: Mon, 26 May 2025 11:05:58 +0700 Subject: [PATCH 16/22] Remove the unnecessary flag '--pr_description.final_update_message=false' --- pr_agent/settings/configuration.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pr_agent/settings/configuration.toml b/pr_agent/settings/configuration.toml index 7ef6c4f2..cdb6d5b9 100644 --- a/pr_agent/settings/configuration.toml +++ b/pr_agent/settings/configuration.toml @@ -285,7 +285,7 @@ push_commands = [ url = "https://gitea.com" handle_push_trigger = false pr_commands = [ - "/describe --pr_description.final_update_message=false", + "/describe", "/review", "/improve", ] From a975b323760e4d6ddf41e3cc5c0528d037ea342f Mon Sep 17 00:00:00 2001 From: Pinyoo Thotaboot Date: Mon, 26 May 2025 11:26:16 +0700 Subject: [PATCH 17/22] Get empty content when exception --- pr_agent/git_providers/gitea_provider.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pr_agent/git_providers/gitea_provider.py b/pr_agent/git_providers/gitea_provider.py index 4ac2e63b..01b2fca7 100644 --- a/pr_agent/git_providers/gitea_provider.py +++ b/pr_agent/git_providers/gitea_provider.py @@ -113,6 +113,7 @@ class GiteaProvider(GitProvider): 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: From b264f42e3d5be04ab0360f6f66566a2a70753e8d Mon Sep 17 00:00:00 2001 From: Pinyoo Thotaboot Date: Mon, 26 May 2025 11:31:40 +0700 Subject: [PATCH 18/22] Fixed handle verify signature when has failed --- pr_agent/servers/gitea_app.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pr_agent/servers/gitea_app.py b/pr_agent/servers/gitea_app.py index 85399f2c..018a746d 100644 --- a/pr_agent/servers/gitea_app.py +++ b/pr_agent/servers/gitea_app.py @@ -51,7 +51,11 @@ async def get_body(request: Request): get_logger().error("Missing signature header") raise HTTPException(status_code=400, detail="Missing signature header") - verify_signature(body_bytes, webhook_secret, f"sha256={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 From f06ee951d7761c336df79d1bcd59ebeb77c7b0e8 Mon Sep 17 00:00:00 2001 From: Pinyoo Thotaboot Date: Mon, 26 May 2025 11:36:49 +0700 Subject: [PATCH 19/22] Change raise runtime error --- pr_agent/git_providers/gitea_provider.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pr_agent/git_providers/gitea_provider.py b/pr_agent/git_providers/gitea_provider.py index 01b2fca7..974c6a97 100644 --- a/pr_agent/git_providers/gitea_provider.py +++ b/pr_agent/git_providers/gitea_provider.py @@ -760,7 +760,7 @@ class RepoApi(giteapy.RepositoryApi): else: error_msg = f"Unexpected response format received from API: {type(response)}" self.logger.error(error_msg) - return RuntimeError(error_msg) + raise RuntimeError(error_msg) except ApiException as e: self.logger.error(f"Error getting diff: {str(e)}") From 5d105c64d2a6c24de08f5cc9e8f9409583135196 Mon Sep 17 00:00:00 2001 From: Pinyoo Thotaboot Date: Mon, 26 May 2025 11:40:29 +0700 Subject: [PATCH 20/22] Rename & Return comment object after published --- pr_agent/git_providers/gitea_provider.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/pr_agent/git_providers/gitea_provider.py b/pr_agent/git_providers/gitea_provider.py index 974c6a97..9e9e1bef 100644 --- a/pr_agent/git_providers/gitea_provider.py +++ b/pr_agent/git_providers/gitea_provider.py @@ -232,26 +232,28 @@ class GiteaProvider(GitProvider): return None comment = self.limit_output_characters(comment, self.max_comment_chars) - reponse = self.repo_api.create_comment( + response = self.repo_api.create_comment( owner=self.owner, repo=self.repo, index=index, comment=comment ) - if not reponse: + if not response: self.logger.error("Failed to publish comment") return None if is_temporary: self.temp_comments.append(comment) - self.comments_list.append({ + comment_obj = { "is_temporary": is_temporary, "comment": comment, - "comment_id": reponse.id if isinstance(reponse, tuple) else reponse.id - }) + "comment_id": response.id if isinstance(response, tuple) else response.id + } + self.comments_list.append(comment_obj) self.logger.info("Comment published") + return comment_obj def edit_comment(self, comment, body : str): body = self.limit_output_characters(body, self.max_comment_chars) From 6063bf59789fe0f3258e6bb97f5ad784b9d0ca5d Mon Sep 17 00:00:00 2001 From: Pinyoo Thotaboot Date: Mon, 26 May 2025 11:42:09 +0700 Subject: [PATCH 21/22] Check is tempolary before remove it --- pr_agent/git_providers/gitea_provider.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pr_agent/git_providers/gitea_provider.py b/pr_agent/git_providers/gitea_provider.py index 9e9e1bef..8805d8f4 100644 --- a/pr_agent/git_providers/gitea_provider.py +++ b/pr_agent/git_providers/gitea_provider.py @@ -649,7 +649,7 @@ class GiteaProvider(GitProvider): return try: - comment_id = comment.get("comment_id") + 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 @@ -659,7 +659,7 @@ class GiteaProvider(GitProvider): comment_id=comment_id ) - if self.comments_list: + if self.comments_list and comment in self.comments_list: self.comments_list.remove(comment) self.logger.info(f"Comment removed successfully: {comment}") @@ -671,6 +671,8 @@ class GiteaProvider(GitProvider): """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}") From b18a509120e3ac7289b968c45b32ed411f1e7054 Mon Sep 17 00:00:00 2001 From: Pinyoo Thotaboot Date: Mon, 26 May 2025 11:44:39 +0700 Subject: [PATCH 22/22] Use current --- pr_agent/git_providers/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pr_agent/git_providers/__init__.py b/pr_agent/git_providers/__init__.py index e4acfc22..055cdbf1 100644 --- a/pr_agent/git_providers/__init__.py +++ b/pr_agent/git_providers/__init__.py @@ -23,7 +23,7 @@ _GIT_PROVIDERS = { 'codecommit': CodeCommitProvider, 'local': LocalGitProvider, 'gerrit': GerritProvider, - 'gitea': GiteaProvider, + 'gitea': GiteaProvider }