From 652ced5406baed24ce9f6808303ab0bd7f2ff9ca Mon Sep 17 00:00:00 2001 From: mrT23 Date: Fri, 26 Jul 2024 08:31:21 +0300 Subject: [PATCH] bitbucket server --- .../bitbucket_server_provider.py | 51 +++++++++--- pr_agent/servers/bitbucket_server_webhook.py | 83 +++++++++++++++++-- 2 files changed, 115 insertions(+), 19 deletions(-) diff --git a/pr_agent/git_providers/bitbucket_server_provider.py b/pr_agent/git_providers/bitbucket_server_provider.py index df4dfaa67..10d032298 100644 --- a/pr_agent/git_providers/bitbucket_server_provider.py +++ b/pr_agent/git_providers/bitbucket_server_provider.py @@ -16,7 +16,7 @@ class BitbucketServerProvider(GitProvider): def __init__( - self, pr_url: Optional[str] = None, incremental: Optional[bool] = False + self, pr_url: Optional[str] = None, incremental: Optional[bool] = False ): s = requests.Session() try: @@ -59,7 +59,7 @@ def get_repo_settings(self): return contents except Exception: return "" - + def get_pr_id(self): return self.pr_num @@ -115,8 +115,11 @@ def publish_code_suggestions(self, code_suggestions: list) -> bool: get_logger().error(f"Failed to publish code suggestion, error: {e}") return False + def publish_file_comments(self, file_comments: list) -> bool: + pass + def is_supported(self, capability: str) -> bool: - if capability in ['get_issue_comments', 'get_labels', 'gfm_markdown']: + if capability in ['get_issue_comments', 'get_labels', 'gfm_markdown', 'publish_file_comments']: return False return True @@ -284,10 +287,10 @@ def publish_inline_comments(self, comments: list[dict]): for comment in comments: if 'position' in comment: self.publish_inline_comment(comment['body'], comment['position'], comment['path']) - elif 'start_line' in comment: # multi-line comment + elif 'start_line' in comment: # multi-line comment # note that bitbucket does not seem to support range - only a comment on a single line - https://community.developer.atlassian.com/t/api-post-endpoint-for-inline-pull-request-comments/60452 self.publish_inline_comment(comment['body'], comment['start_line'], comment['path']) - elif 'line' in comment: # single-line comment + elif 'line' in comment: # single-line comment self.publish_inline_comment(comment['body'], comment['line'], comment['path']) else: get_logger().error(f"Could not publish inline comment: {comment}") @@ -301,6 +304,9 @@ def get_languages(self): def get_pr_branch(self): return self.pr.fromRef['displayId'] + def get_pr_owner_id(self) -> str | None: + return self.workspace_slug + def get_pr_description_full(self): if hasattr(self.pr, "description"): return self.pr.description @@ -323,14 +329,29 @@ def remove_reaction(self, issue_comment_id: int, reaction_id: int) -> bool: @staticmethod def _parse_bitbucket_server(url: str) -> str: + # pr url format: f"{bitbucket_server}/projects/{project_name}/repos/{repository_name}/pull-requests/{pr_id}" parsed_url = urlparse(url) + server_path = parsed_url.path.split("/projects/") + if len(server_path) > 1: + server_path = server_path[0].strip("/") + return f"{parsed_url.scheme}://{parsed_url.netloc}/{server_path}".strip("/") return f"{parsed_url.scheme}://{parsed_url.netloc}" @staticmethod def _parse_pr_url(pr_url: str) -> Tuple[str, str, int]: + # pr url format: f"{bitbucket_server}/projects/{project_name}/repos/{repository_name}/pull-requests/{pr_id}" parsed_url = urlparse(pr_url) + path_parts = parsed_url.path.strip("/").split("/") - if len(path_parts) < 6 or path_parts[4] != "pull-requests": + + try: + projects_index = path_parts.index("projects") + except ValueError as e: + raise ValueError(f"The provided URL '{pr_url}' does not appear to be a Bitbucket PR URL") + + path_parts = path_parts[projects_index:] + + if len(path_parts) < 6 or path_parts[2] != "repos" or path_parts[4] != "pull-requests": raise ValueError( f"The provided URL '{pr_url}' does not appear to be a Bitbucket PR URL" ) @@ -350,34 +371,38 @@ def _get_repo(self): return self.repo def _get_pr(self): - pr = self.bitbucket_client.get_pull_request(self.workspace_slug, self.repo_slug, pull_request_id=self.pr_num) - return type('new_dict', (object,), pr) + try: + pr = self.bitbucket_client.get_pull_request(self.workspace_slug, self.repo_slug, + pull_request_id=self.pr_num) + return type('new_dict', (object,), pr) + except Exception as e: + get_logger().error(f"Failed to get pull request, error: {e}") + raise e def _get_pr_file_content(self, remote_link: str): return "" def get_commit_messages(self): - def get_commit_messages(self): - raise NotImplementedError("Get commit messages function not implemented yet.") + return "" + # bitbucket does not support labels def publish_description(self, pr_title: str, description: str): payload = { "version": self.pr.version, "description": description, "title": pr_title, - "reviewers": self.pr.reviewers # needs to be sent otherwise gets wiped + "reviewers": self.pr.reviewers # needs to be sent otherwise gets wiped } try: self.bitbucket_client.update_pull_request(self.workspace_slug, self.repo_slug, str(self.pr_num), payload) except Exception as e: get_logger().error(f"Failed to update pull request, error: {e}") raise e - # bitbucket does not support labels def publish_labels(self, pr_types: list): pass - + # bitbucket does not support labels def get_pr_labels(self, update=False): pass diff --git a/pr_agent/servers/bitbucket_server_webhook.py b/pr_agent/servers/bitbucket_server_webhook.py index 079af9950..c9bfa5d9d 100644 --- a/pr_agent/servers/bitbucket_server_webhook.py +++ b/pr_agent/servers/bitbucket_server_webhook.py @@ -1,5 +1,7 @@ +import ast import json import os +from typing import List import uvicorn from fastapi import APIRouter, FastAPI @@ -10,11 +12,14 @@ from starlette.requests import Request from starlette.responses import JSONResponse from starlette_context.middleware import RawContextMiddleware - from pr_agent.agent.pr_agent import PRAgent +from pr_agent.algo.utils import update_settings_from_args from pr_agent.config_loader import get_settings +from pr_agent.git_providers.utils import apply_repo_settings from pr_agent.log import LoggingFormat, get_logger, setup_logger from pr_agent.servers.utils import verify_signature +from fastapi.responses import RedirectResponse + setup_logger(fmt=LoggingFormat.JSON, level="DEBUG") router = APIRouter() @@ -25,7 +30,7 @@ def handle_request( ): log_context["action"] = body log_context["api_url"] = url - + async def inner(): try: with get_logger().contextualize(**log_context): @@ -35,8 +40,11 @@ async def inner(): background_tasks.add_task(inner) - @router.post("/") +async def redirect_to_webhook(): + return RedirectResponse(url="/webhook") + +@router.post("/webhook") async def handle_webhook(background_tasks: BackgroundTasks, request: Request): log_context = {"server_type": "bitbucket_server"} data = await request.json() @@ -45,6 +53,10 @@ async def handle_webhook(background_tasks: BackgroundTasks, request: Request): webhook_secret = get_settings().get("BITBUCKET_SERVER.WEBHOOK_SECRET", None) if webhook_secret: body_bytes = await request.body() + if body_bytes.decode('utf-8') == '{"test": true}': + return JSONResponse( + status_code=status.HTTP_200_OK, content=jsonable_encoder({"message": "connection test successful"}) + ) signature_header = request.headers.get("x-hub-signature", None) verify_signature(body_bytes, webhook_secret, signature_header) @@ -57,22 +69,81 @@ async def handle_webhook(background_tasks: BackgroundTasks, request: Request): log_context["api_url"] = pr_url log_context["event"] = "pull_request" + commands_to_run = [] + if data["eventKey"] == "pr:opened": - body = "review" + commands_to_run.extend(_get_commands_list_from_settings('BITBUCKET_SERVER.PR_COMMANDS')) elif data["eventKey"] == "pr:comment:added": - body = data["comment"]["text"] + commands_to_run.append(data["comment"]["text"]) else: return JSONResponse( status_code=status.HTTP_400_BAD_REQUEST, content=json.dumps({"message": "Unsupported event"}), ) - handle_request(background_tasks, pr_url, body, log_context) + async def inner(): + try: + await _run_commands_sequentially(commands_to_run, pr_url, log_context) + except Exception as e: + get_logger().error(f"Failed to handle webhook: {e}") + + background_tasks.add_task(inner) + return JSONResponse( status_code=status.HTTP_200_OK, content=jsonable_encoder({"message": "success"}) ) +async def _run_commands_sequentially(commands: List[str], url: str, log_context: dict): + get_logger().info(f"Running commands sequentially: {commands}") + if commands is None: + return + + for command in commands: + try: + body = _process_command(command, url) + + log_context["action"] = body + log_context["api_url"] = url + + with get_logger().contextualize(**log_context): + await PRAgent().handle_request(url, body) + except Exception as e: + get_logger().error(f"Failed to handle command: {command} , error: {e}") + +def _process_command(command: str, url) -> str: + # don't think we need this + apply_repo_settings(url) + # Process the command string + split_command = command.split(" ") + command = split_command[0] + args = split_command[1:] + # do I need this? if yes, shouldn't this be done in PRAgent? + other_args = update_settings_from_args(args) + new_command = ' '.join([command] + other_args) + return new_command + + +def _to_list(command_string: str) -> list: + try: + # Use ast.literal_eval to safely parse the string into a list + commands = ast.literal_eval(command_string) + # Check if the parsed object is a list of strings + if isinstance(commands, list) and all(isinstance(cmd, str) for cmd in commands): + return commands + else: + raise ValueError("Parsed data is not a list of strings.") + except (SyntaxError, ValueError, TypeError) as e: + raise ValueError(f"Invalid command string: {e}") + + +def _get_commands_list_from_settings(setting_key:str ) -> list: + try: + return get_settings().get(setting_key, []) + except ValueError as e: + get_logger().error(f"Failed to get commands list from settings {setting_key}: {e}") + + @router.get("/") async def root(): return {"status": "ok"}