diff --git a/README.md b/README.md index ade64dcaae..0aa517353b 100644 --- a/README.md +++ b/README.md @@ -161,7 +161,7 @@ pr-agent --pr_url https://github.com/owner/repo/pull/123 review PR-Agent offers comprehensive pull request functionalities integrated with various git providers: -| | | GitHub | GitLab | Bitbucket | Azure DevOps | Gitea | +| | | GitHub | GitLab | Bitbucket | Azure DevOps | Devstar/Gitea | |---------------------------------------------------------|----------------------------------------------------------------------------------------|:------:|:------:|:---------:|:------------:|:-----:| | [TOOLS](https://qodo-merge-docs.qodo.ai/tools/) | [Describe](https://qodo-merge-docs.qodo.ai/tools/describe/) | ✅ | ✅ | ✅ | ✅ | ✅ | | | [Review](https://qodo-merge-docs.qodo.ai/tools/review/) | ✅ | ✅ | ✅ | ✅ | ✅ | @@ -174,19 +174,19 @@ PR-Agent offers comprehensive pull request functionalities integrated with vario | [USAGE](https://qodo-merge-docs.qodo.ai/usage-guide/) | [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) | ✅ | ✅ | ✅ | ✅ | | +| | [Actions](https://qodo-merge-docs.qodo.ai/installation/github/#run-as-a-github-action) | ✅ | ✅ | ✅ | ✅ | ✅ | | | | | | | | | | [CORE](https://qodo-merge-docs.qodo.ai/core-abilities/) | [Adaptive and token-aware file patch fitting](https://qodo-merge-docs.qodo.ai/core-abilities/compression_strategy/) | ✅ | ✅ | ✅ | ✅ | | | | [Chat on code suggestions](https://qodo-merge-docs.qodo.ai/core-abilities/chat_on_code_suggestions/) | ✅ | ✅ | | | | | | [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/) | ✅ | ✅ | ✅ | | | +| | [Fetching ticket context](https://qodo-merge-docs.qodo.ai/core-abilities/fetching_ticket_context/) | ✅ | ✅ | ✅ | | ✅ | | | [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/) | ✅ | ✅ | ✅ | ✅ | | | | [Multiple models support](https://qodo-merge-docs.qodo.ai/usage-guide/changing_a_model/) | ✅ | ✅ | ✅ | ✅ | | | | [PR compression](https://qodo-merge-docs.qodo.ai/core-abilities/compression_strategy/) | ✅ | ✅ | ✅ | ✅ | | | | [RAG context enrichment](https://qodo-merge-docs.qodo.ai/core-abilities/rag_context_enrichment/) | ✅ | | ✅ | | | -| | [Self reflection](https://qodo-merge-docs.qodo.ai/core-abilities/self_reflection/) | ✅ | ✅ | ✅ | ✅ | | +| | [Self reflection](https://qodo-merge-docs.qodo.ai/core-abilities/self_reflection/) | ✅ | ✅ | ✅ | ✅ | ✅ | [//]: # (- Support for additional git providers is described in [here](./docs/Full_environments.md)) ___ diff --git a/github_action/entrypoint.sh b/github_action/entrypoint.sh index 4d493c7c79..4c60610143 100644 --- a/github_action/entrypoint.sh +++ b/github_action/entrypoint.sh @@ -1,2 +1,9 @@ #!/bin/bash -python /app/pr_agent/servers/github_action_runner.py +export PYTHONUNBUFFERED=1 + +if [ -n "$GITEA_EVENT_NAME" ] || [ "$GITEA_ACTIONS" == "true" ]; then + python /app/pr_agent/servers/gitea_action_runner.py +else + python /app/pr_agent/servers/github_action_runner.py +fi + diff --git a/pr_agent/git_providers/gitea_provider.py b/pr_agent/git_providers/gitea_provider.py index 89a6248e9b..1c710ebdd9 100644 --- a/pr_agent/git_providers/gitea_provider.py +++ b/pr_agent/git_providers/gitea_provider.py @@ -35,7 +35,7 @@ def __init__(self, url: Optional[str] = None): 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) + self.repo_settings = ".pr_agent.toml" configuration = giteapy.Configuration() configuration.host = "{}/api/v1".format(self.base_url) configuration.api_key['Authorization'] = f'token {self.gitea_access_token}' @@ -543,6 +543,15 @@ def get_num_of_files(self) -> int: """Get number of files changed in the PR""" return len(self.git_files) + def get_issue_main_description(self, issue_num: int) -> str: + """The AI validation logic automatically looks for this method name to retrieve acceptance criteria.""" + try: + issue = self.repo_api.get_issue(self.owner, self.repo, issue_num) + return issue.body if issue and hasattr(issue, 'body') else "" + except Exception as e: + self.logger.error(f"Failed to fetch Gitea issue body: {e}") + return "" + 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 @@ -605,23 +614,25 @@ def get_pr_labels(self,update=False) -> List[str]: 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_repo_settings(self) -> str: + try: + response = self.repo_api.get_file_content( + owner=self.owner, + repo=self.repo, + commit_sha=self.sha, + filepath=".pr_agent.toml" + ) + + + if isinstance(response, str): + return response.encode('utf-8') + return response + except Exception as e: + if "404" in str(e): + self.logger.info("No .pr_agent.toml found, using defaults") + else: + self.logger.error(f"Error fetching settings: {e}") + return b"" # Return an empty bytes object. def get_user_id(self) -> str: """Get the ID of the authenticated user""" @@ -957,6 +968,9 @@ def get_issue_labels(self, owner: str, repo: str, issue_number: int): repo=repo, index=issue_number ) + def get_issue(self, owner: str, repo: str, index: int): + """Get ticket issue """ + return self.issue.issue_get_issue(owner=owner, repo=repo, index=index) def list_all_commits(self, owner: str, repo: str): return self.repository.repo_get_all_commits( diff --git a/pr_agent/servers/gitea_actions_runner.py b/pr_agent/servers/gitea_actions_runner.py new file mode 100644 index 0000000000..e38786a23b --- /dev/null +++ b/pr_agent/servers/gitea_actions_runner.py @@ -0,0 +1,224 @@ +import asyncio +import json +import os +import re +from typing import Union, Dict, Any + +from pr_agent.agent.pr_agent import PRAgent +from pr_agent.config_loader import get_settings +from pr_agent.git_providers.utils import apply_repo_settings +from pr_agent.log import get_logger +from pr_agent.tools.pr_code_suggestions import PRCodeSuggestions +from pr_agent.tools.pr_description import PRDescription +from pr_agent.tools.pr_reviewer import PRReviewer + + +def is_true(value: Union[str, bool]) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.lower() == "true" + return False + + +def get_setting_or_env(key: str, default: Union[str, bool] = None) -> Union[str, bool]: + try: + value = get_settings().get(key, default) + except AttributeError: + value = ( + os.getenv(key, None) + or os.getenv(key.upper(), None) + or os.getenv(key.lower(), None) + or default + ) + return value + + +def normalize_url(url: str) -> str: + if not url: + return url + url = url.strip() + if not url.startswith(("http://", "https://")): + url = f"https://{url}" + return url.rstrip("/") + + +def should_process_pr_logic(body: Dict[str, Any]) -> bool: + """Advanced rule-based filtering: skip specific PRs that do not require review.""" + try: + pull_request = body.get("pull_request", {}) + title = pull_request.get("title", "") + sender = body.get("sender", {}).get("login") + source_branch = pull_request.get("head", {}).get("ref", "") + target_branch = pull_request.get("base", {}).get("ref", "") + + # 1. Ignore specific users + ignore_pr_users = get_settings().get("CONFIG.IGNORE_PR_AUTHORS", []) + if ignore_pr_users and sender: + if any(re.search(regex, sender) for regex in ignore_pr_users): + get_logger().info(f"Skipped: ignoring PR from user '{sender}'") + return False + + # 2. Ignore specific titles + if title: + ignore_pr_title_re = get_settings().get("CONFIG.IGNORE_PR_TITLE", []) + if not isinstance(ignore_pr_title_re, list): + ignore_pr_title_re = [ignore_pr_title_re] + if ignore_pr_title_re and any(re.search(regex, title) for regex in ignore_pr_title_re): + get_logger().info(f"Skipped: ignoring PR with matching title '{title}'") + return False + + # 3. Ignore specific source branches + ignore_pr_source_branches = get_settings().get("CONFIG.IGNORE_PR_SOURCE_BRANCHES", []) + if ignore_pr_source_branches and source_branch: + if any(re.search(regex, source_branch) for regex in ignore_pr_source_branches): + get_logger().info(f"Skipped: ignoring PR from source branch '{source_branch}'") + return False + + # 4. Ignore specific target branches + ignore_pr_target_branches = get_settings().get("CONFIG.IGNORE_PR_TARGET_BRANCHES", []) + if ignore_pr_target_branches and target_branch: + if any(re.search(regex, target_branch) for regex in ignore_pr_target_branches): + get_logger().info(f"Skipped: ignoring PR targeting branch '{target_branch}'") + return False + except Exception as e: + get_logger().error(f"Failed to execute should_process_pr_logic: {e}") + return True + + +async def run_action(): + # 1. Read environment variables + EVENT_NAME = os.environ.get("GITEA_EVENT_NAME") or os.environ.get("GITHUB_EVENT_NAME") + EVENT_PATH = os.environ.get("GITEA_EVENT_PATH") or os.environ.get("GITHUB_EVENT_PATH") + TOKEN = os.environ.get("GITEA_TOKEN") or os.environ.get("GITHUB_TOKEN") + OPENAI_KEY = os.environ.get("OPENAI_KEY") or os.environ.get("OPENAI.KEY") + OPENAI_ORG = os.environ.get("OPENAI_ORG") or os.environ.get("OPENAI.ORG") + DEVSTAR_URL = os.environ.get("DEVSTAR_URL") + GITEA_URL = os.environ.get("GITEA_URL") + FINAL_GITEA_URL = normalize_url(DEVSTAR_URL or GITEA_URL or "https://gitea.com") + + if not EVENT_NAME or not EVENT_PATH or not TOKEN: + get_logger().error( + "Container startup failed: missing required environment variables (EVENT_NAME, EVENT_PATH, TOKEN)." + ) + return + + # 2. Configure Git provider identity and self-hosted address + get_settings().set("config.git_provider", "gitea") + get_settings().set("gitea.personal_access_token", TOKEN) + + if OPENAI_KEY: + get_settings().set("OPENAI.KEY", OPENAI_KEY) + else: + print("OPENAI_KEY not set") + + if OPENAI_ORG: + get_settings().set("OPENAI.ORG", OPENAI_ORG) + + get_settings().set("gitea.url", FINAL_GITEA_URL) + get_settings().set("gitea.api_url", f"{FINAL_GITEA_URL}/api/v1") + + if DEVSTAR_URL: + get_logger().info(f"Resolved Gitea URL from DEVSTAR_URL: {FINAL_GITEA_URL}") + elif GITEA_URL: + get_logger().info(f"Resolved Gitea URL from GITEA_URL: {FINAL_GITEA_URL}") + else: + get_logger().info(f"No URL configuration detected, using default:{FINAL_GITEA_URL}") + + get_settings().set("GITHUB.USER_TOKEN", TOKEN) + get_settings().set("GITHUB.DEPLOYMENT_TYPE", "user") + + # 3. Read the local payload JSON + try: + with open(EVENT_PATH, "r") as f: + event_payload = json.load(f) + except Exception as e: + get_logger().error(f"Failed to parse payload JSON: {e}") + return + + # 4. Extract the PR URL + pr_url = None + if EVENT_NAME in ["pull_request", "pull_request_target"]: + pr_url = event_payload.get("pull_request", {}).get("html_url") + elif EVENT_NAME == "issue_comment": + issue_data = event_payload.get("issue", {}) + if "pull_request" in issue_data: + pr_url = issue_data.get("pull_request", {}).get("html_url") or issue_data.get("html_url") + + if not pr_url: + get_logger().warning("No valid PR URL found in the payload. Exiting.") + return + + # 5. Apply repository-level configuration (.pr_agent.toml) + try: + apply_repo_settings(pr_url) + except Exception as e: + get_logger().warning(f"Failed to apply repository settings: {e}") + + if os.path.exists(".pr_agent.toml"): + get_logger().info("Loaded local .pr_agent.toml") + get_settings().load_file(".pr_agent.toml") + elif os.path.exists("/workspace/.pr_agent.toml"): + get_settings().load_file("/workspace/.pr_agent.toml") + get_logger().info("Loaded local .pr_agent.toml") + + # 6. Inject multilingual instruction prompts + try: + response_language = get_settings().config.get("response_language", "en-us") + if response_language.lower() != "en-us": + get_logger().info(f"Custom response language detected: {response_language}") + lang_instruction_text = ( + f"Your response MUST be written in the language corresponding to locale code: " + f"'{response_language}'. This is crucial." + ) + separator_text = "\n======\n\nIn addition, " + + for key in get_settings(): + setting = get_settings().get(key) + if str(type(setting)) == "": + if key.lower() in ["pr_description", "pr_code_suggestions", "pr_reviewer"]: + if hasattr(setting, "extra_instructions"): + extra_instructions = setting.extra_instructions + if lang_instruction_text not in str(extra_instructions): + updated_instructions = ( + str(extra_instructions) + separator_text + lang_instruction_text + if extra_instructions + else lang_instruction_text + ) + setting.extra_instructions = updated_instructions + except Exception as e: + get_logger().warning(f"Instruction injection failed: {e}") + + # 7. Core event routing and execution + if EVENT_NAME in ["pull_request", "pull_request_target"]: + if not should_process_pr_logic(event_payload): + return + + action = event_payload.get("action") + if action in ["opened", "reopened", "ready_for_review", "synchronize", "synchronized"]: + get_settings().config.is_auto_command = True + get_logger().info(f"Triggered automated review action: {action}") + + auto_describe = get_setting_or_env("GITHUB_ACTION_CONFIG.AUTO_DESCRIBE", True) + auto_review = get_setting_or_env("GITHUB_ACTION_CONFIG.AUTO_REVIEW", True) + auto_improve = get_setting_or_env("GITHUB_ACTION_CONFIG.AUTO_IMPROVE", True) + + if is_true(auto_describe) and action not in ["synchronize", "synchronized"]: + await PRDescription(pr_url).run() + if is_true(auto_review): + await PRReviewer(pr_url).run() + if is_true(auto_improve): + await PRCodeSuggestions(pr_url).run() + else: + get_logger().info(f"Ignoring unconfigured PR action: {action}") + + elif EVENT_NAME == "issue_comment": + action = event_payload.get("action") + if action in ["created", "edited"]: + comment_body = event_payload.get("comment", {}).get("body", "").strip() + if comment_body.startswith("/"): + get_logger().info(f"Received user command: {comment_body}") + await PRAgent().handle_request(pr_url, comment_body) + +if __name__ == "__main__": + asyncio.run(run_action()) \ No newline at end of file diff --git a/pr_agent/tools/ticket_pr_compliance_check.py b/pr_agent/tools/ticket_pr_compliance_check.py index 6d25d76b19..2994c9b236 100644 --- a/pr_agent/tools/ticket_pr_compliance_check.py +++ b/pr_agent/tools/ticket_pr_compliance_check.py @@ -4,6 +4,7 @@ from pr_agent.config_loader import get_settings from pr_agent.git_providers import GithubProvider from pr_agent.git_providers import AzureDevopsProvider +from pr_agent.git_providers import GiteaProvider from pr_agent.log import get_logger # Compile the regex pattern once, outside the function @@ -215,7 +216,38 @@ async def extract_tickets(git_provider): artifact={"traceback": traceback.format_exc()}, ) return tickets_content + elif isinstance(git_provider, GiteaProvider): + import re + user_description = git_provider.get_pr_description_full() + + ticket_numbers = list(set(re.findall(r'#(\d+)', user_description))) + tickets_content = [] + for ticket_num_str in ticket_numbers: + try: + ticket_num = int(ticket_num_str) + issue = git_provider.repo_api.get_issue(git_provider.owner, git_provider.repo, ticket_num) + + if issue: + issue_body_str = issue.body or "" + if len(issue_body_str) > MAX_TICKET_CHARACTERS: + issue_body_str = issue_body_str[:MAX_TICKET_CHARACTERS] + "..." + + labels = [] + if hasattr(issue, 'labels') and issue.labels: + labels = [label.name for label in issue.labels] + + tickets_content.append({ + 'ticket_id': ticket_num, + 'ticket_url': f"{git_provider.base_url}/{git_provider.owner}/{git_provider.repo}/issues/{ticket_num}", + 'title': issue.title if hasattr(issue, 'title') else "", + 'body': issue_body_str, + 'labels': ", ".join(labels) + }) + except Exception as e: + get_logger().warning(f"Failed to fetch Gitea issue {ticket_num_str}: {e}") + + return tickets_content except Exception as e: get_logger().error(f"Error extracting tickets error= {e}", artifact={"traceback": traceback.format_exc()})