Skip to content

Commit

Permalink
Merge pull request #1059 from Codium-ai/tr/bitbucket_server
Browse files Browse the repository at this point in the history
bitbucket server
  • Loading branch information
mrT23 authored Jul 27, 2024
2 parents aaf0375 + 652ced5 commit 0bd4c9b
Show file tree
Hide file tree
Showing 2 changed files with 115 additions and 19 deletions.
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

0 comments on commit 0bd4c9b

Please sign in to comment.