Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

bitbucket server auto commands #1059

Merged
merged 1 commit into from
Jul 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 38 additions & 13 deletions pr_agent/git_providers/bitbucket_server_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -59,7 +59,7 @@ def get_repo_settings(self):
return contents
except Exception:
return ""

def get_pr_id(self):
return self.pr_num

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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}")
Expand All @@ -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
Expand All @@ -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"
)
Expand All @@ -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
Expand Down
83 changes: 77 additions & 6 deletions pr_agent/servers/bitbucket_server_webhook.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import ast
import json
import os
from typing import List

import uvicorn
from fastapi import APIRouter, FastAPI
Expand All @@ -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()
Expand All @@ -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):
Expand All @@ -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()
Expand All @@ -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)

Expand All @@ -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"}
Expand Down
Loading