Bitbucket server, WIP

This commit is contained in:
Ori Kotek
2023-08-24 18:35:41 +03:00
parent 5079daa4ad
commit 355abfc39a
6 changed files with 174 additions and 79 deletions

View File

@ -9,6 +9,10 @@ FROM base as github_app
ADD pr_agent pr_agent ADD pr_agent pr_agent
CMD ["python", "pr_agent/servers/github_app.py"] CMD ["python", "pr_agent/servers/github_app.py"]
FROM base as bitbucket_app
ADD pr_agent pr_agent
CMD ["python", "pr_agent/servers/bitbucket_app.py"]
FROM base as github_polling FROM base as github_polling
ADD pr_agent pr_agent ADD pr_agent pr_agent
CMD ["python", "pr_agent/servers/github_polling.py"] CMD ["python", "pr_agent/servers/github_polling.py"]

View File

@ -12,14 +12,18 @@ from .git_provider import FilePatchInfo, GitProvider
class BitbucketProvider(GitProvider): class BitbucketProvider(GitProvider):
def __init__(self, pr_url: Optional[str] = None, incremental: Optional[bool] = False): def __init__(
self, pr_url: Optional[str] = None, incremental: Optional[bool] = False
):
s = requests.Session() s = requests.Session()
try: try:
bearer = context.get("bitbucket_bearer_token", None) bearer = context.get("bitbucket_bearer_token", None)
s.headers['Authorization'] = f'Bearer {bearer}' s.headers["Authorization"] = f"Bearer {bearer}"
except Exception: except Exception:
s.headers['Authorization'] = f'Bearer {get_settings().get("BITBUCKET.BEARER_TOKEN", None)}' s.headers[
s.headers['Content-Type'] = 'application/json' "Authorization"
] = f'Bearer {get_settings().get("BITBUCKET.BEARER_TOKEN", None)}'
s.headers["Content-Type"] = "application/json"
self.headers = s.headers self.headers = s.headers
self.bitbucket_client = Cloud(session=s) self.bitbucket_client = Cloud(session=s)
self.workspace_slug = None self.workspace_slug = None
@ -31,11 +35,15 @@ class BitbucketProvider(GitProvider):
self.incremental = incremental self.incremental = incremental
if pr_url: if pr_url:
self.set_pr(pr_url) self.set_pr(pr_url)
self.bitbucket_comment_api_url = self.pr._BitbucketBase__data['links']['comments']['href'] self.bitbucket_comment_api_url = self.pr._BitbucketBase__data["links"][
"comments"
]["href"]
def get_repo_settings(self): def get_repo_settings(self):
try: try:
contents = self.repo_obj.get_contents(".pr_agent.toml", ref=self.pr.head.sha).decoded_content contents = self.repo_obj.get_contents(
".pr_agent.toml", ref=self.pr.head.sha
).decoded_content
return contents return contents
except Exception: except Exception:
return "" return ""
@ -46,22 +54,25 @@ class BitbucketProvider(GitProvider):
""" """
post_parameters_list = [] post_parameters_list = []
for suggestion in code_suggestions: for suggestion in code_suggestions:
body = suggestion['body'] body = suggestion["body"]
relevant_file = suggestion['relevant_file'] relevant_file = suggestion["relevant_file"]
relevant_lines_start = suggestion['relevant_lines_start'] relevant_lines_start = suggestion["relevant_lines_start"]
relevant_lines_end = suggestion['relevant_lines_end'] relevant_lines_end = suggestion["relevant_lines_end"]
if not relevant_lines_start or relevant_lines_start == -1: if not relevant_lines_start or relevant_lines_start == -1:
if get_settings().config.verbosity_level >= 2: if get_settings().config.verbosity_level >= 2:
logging.exception( logging.exception(
f"Failed to publish code suggestion, relevant_lines_start is {relevant_lines_start}") f"Failed to publish code suggestion, relevant_lines_start is {relevant_lines_start}"
)
continue continue
if relevant_lines_end < relevant_lines_start: if relevant_lines_end < relevant_lines_start:
if get_settings().config.verbosity_level >= 2: if get_settings().config.verbosity_level >= 2:
logging.exception(f"Failed to publish code suggestion, " logging.exception(
f"Failed to publish code suggestion, "
f"relevant_lines_end is {relevant_lines_end} and " f"relevant_lines_end is {relevant_lines_end} and "
f"relevant_lines_start is {relevant_lines_start}") f"relevant_lines_start is {relevant_lines_start}"
)
continue continue
if relevant_lines_end > relevant_lines_start: if relevant_lines_end > relevant_lines_start:
@ -81,7 +92,6 @@ class BitbucketProvider(GitProvider):
} }
post_parameters_list.append(post_parameters) post_parameters_list.append(post_parameters)
try: try:
self.publish_inline_comments(post_parameters_list) self.publish_inline_comments(post_parameters_list)
return True return True
@ -91,7 +101,12 @@ class BitbucketProvider(GitProvider):
return False return False
def is_supported(self, capability: str) -> bool: def is_supported(self, capability: str) -> bool:
if capability in ['get_issue_comments', 'create_inline_comment', 'publish_inline_comments', 'get_labels']: if capability in [
"get_issue_comments",
"create_inline_comment",
"publish_inline_comments",
"get_labels",
]:
return False return False
return True return True
@ -104,57 +119,65 @@ class BitbucketProvider(GitProvider):
def get_diff_files(self) -> list[FilePatchInfo]: def get_diff_files(self) -> list[FilePatchInfo]:
diffs = self.pr.diffstat() diffs = self.pr.diffstat()
diff_split = ['diff --git%s' % x for x in self.pr.diff().split('diff --git') if x.strip()] diff_split = [
"diff --git%s" % x for x in self.pr.diff().split("diff --git") if x.strip()
]
diff_files = [] diff_files = []
for index, diff in enumerate(diffs): for index, diff in enumerate(diffs):
original_file_content_str = self._get_pr_file_content(diff.old.get_data('links')) original_file_content_str = self._get_pr_file_content(
new_file_content_str = self._get_pr_file_content(diff.new.get_data('links')) diff.old.get_data("links")
diff_files.append(FilePatchInfo(original_file_content_str, new_file_content_str, )
diff_split[index], diff.new.path)) new_file_content_str = self._get_pr_file_content(diff.new.get_data("links"))
diff_files.append(
FilePatchInfo(
original_file_content_str,
new_file_content_str,
diff_split[index],
diff.new.path,
)
)
return diff_files return diff_files
def publish_comment(self, pr_comment: str, is_temporary: bool = False): def publish_comment(self, pr_comment: str, is_temporary: bool = False):
comment = self.pr.comment(pr_comment) comment = self.pr.comment(pr_comment)
if is_temporary: if is_temporary:
self.temp_comments.append(comment['id']) self.temp_comments.append(comment["id"])
def remove_initial_comment(self): def remove_initial_comment(self):
try: try:
for comment in self.temp_comments: for comment in self.temp_comments:
self.pr.delete(f'comments/{comment}') self.pr.delete(f"comments/{comment}")
except Exception as e: except Exception as e:
logging.exception(f"Failed to remove temp comments, error: {e}") logging.exception(f"Failed to remove temp comments, error: {e}")
def publish_inline_comment(self, comment: str, from_line: int, to_line: int, file: str): def publish_inline_comment(
payload = json.dumps( { self, comment: str, from_line: int, to_line: int, file: str
):
payload = json.dumps(
{
"content": { "content": {
"raw": comment, "raw": comment,
}, },
"inline": { "inline": {"to": from_line, "path": file},
"to": from_line, }
"path": file )
},
})
response = requests.request( response = requests.request(
"POST", "POST", self.bitbucket_comment_api_url, data=payload, headers=self.headers
self.bitbucket_comment_api_url,
data=payload,
headers=self.headers
) )
return response return response
def publish_inline_comments(self, comments: list[dict]): def publish_inline_comments(self, comments: list[dict]):
for comment in comments: for comment in comments:
self.publish_inline_comment(comment['body'], comment['start_line'], comment['line'], comment['path']) self.publish_inline_comment(
comment["body"], comment["start_line"], comment["line"], comment["path"]
)
def get_title(self): def get_title(self):
return self.pr.title return self.pr.title
def get_languages(self): def get_languages(self):
languages = {self._get_repo().get_data('language'): 0} languages = {self._get_repo().get_data("language"): 0}
return languages return languages
def get_pr_branch(self): def get_pr_branch(self):
@ -167,7 +190,9 @@ class BitbucketProvider(GitProvider):
return 0 return 0
def get_issue_comments(self): def get_issue_comments(self):
raise NotImplementedError("Bitbucket provider does not support issue comments yet") raise NotImplementedError(
"Bitbucket provider does not support issue comments yet"
)
def add_eyes_reaction(self, issue_comment_id: int) -> Optional[int]: def add_eyes_reaction(self, issue_comment_id: int) -> Optional[int]:
return True return True
@ -179,13 +204,15 @@ class BitbucketProvider(GitProvider):
def _parse_pr_url(pr_url: str) -> Tuple[str, int]: def _parse_pr_url(pr_url: str) -> Tuple[str, int]:
parsed_url = urlparse(pr_url) parsed_url = urlparse(pr_url)
if 'bitbucket.org' not in parsed_url.netloc: if "bitbucket.org" not in parsed_url.netloc:
raise ValueError("The provided URL is not a valid Bitbucket URL") raise ValueError("The provided URL is not a valid Bitbucket URL")
path_parts = parsed_url.path.strip('/').split('/') path_parts = parsed_url.path.strip("/").split("/")
if len(path_parts) < 4 or path_parts[2] != 'pull-requests': if len(path_parts) < 4 or path_parts[2] != "pull-requests":
raise ValueError("The provided URL does not appear to be a Bitbucket PR URL") raise ValueError(
"The provided URL does not appear to be a Bitbucket PR URL"
)
workspace_slug = path_parts[0] workspace_slug = path_parts[0]
repo_slug = path_parts[1] repo_slug = path_parts[1]
@ -198,7 +225,9 @@ class BitbucketProvider(GitProvider):
def _get_repo(self): def _get_repo(self):
if self.repo is None: if self.repo is None:
self.repo = self.bitbucket_client.workspaces.get(self.workspace_slug).repositories.get(self.repo_slug) self.repo = self.bitbucket_client.workspaces.get(
self.workspace_slug
).repositories.get(self.repo_slug)
return self.repo return self.repo
def _get_pr(self): def _get_pr(self):
@ -209,3 +238,16 @@ class BitbucketProvider(GitProvider):
def get_commit_messages(self): def get_commit_messages(self):
return "" # not implemented yet return "" # not implemented yet
def publish_description(self, pr_title: str, pr_body: str):
pass
def create_inline_comment(
self, body: str, relevant_file: str, relevant_line_in_file: str
):
pass
def publish_labels(self, labels):
pass
def get_labels(self):
pass

View File

@ -0,0 +1,33 @@
{
"name": "CodiumAI PR-Agent",
"description": "CodiumAI PR-Agent",
"key": "app_key",
"vendor": {
"name": "CodiumAI",
"url": "https://codium.ai"
},
"authentication": {
"type": "jwt"
},
"baseUrl": "https://53e2-212-199-118-78.ngrok-free.app",
"lifecycle": {
"installed": "/installed",
"uninstalled": "/uninstalled"
},
"scopes": [
"account",
"repository",
"pullrequest"
],
"contexts": [
"account"
],
"modules": {
"webhooks": [
{
"event": "*",
"url": "/webhook"
}
]
}
}

View File

@ -1,22 +1,26 @@
import copy
import hashlib import hashlib
import json import json
import logging import logging
import os import os
import sys
import time import time
import jwt import jwt
import requests import requests
import uvicorn import uvicorn
from fastapi import APIRouter, FastAPI, Request, Response from fastapi import APIRouter, FastAPI, Request, Response
from starlette.background import BackgroundTasks
from starlette.middleware import Middleware from starlette.middleware import Middleware
from starlette.responses import JSONResponse from starlette.responses import JSONResponse
from starlette_context import context from starlette_context import context
from starlette_context.middleware import RawContextMiddleware from starlette_context.middleware import RawContextMiddleware
from pr_agent.agent.pr_agent import PRAgent from pr_agent.agent.pr_agent import PRAgent
from pr_agent.config_loader import get_settings from pr_agent.config_loader import get_settings, global_settings
from pr_agent.secret_providers import get_secret_provider from pr_agent.secret_providers import get_secret_provider
logging.basicConfig(stream=sys.stdout, level=logging.INFO)
router = APIRouter() router = APIRouter()
secret_provider = get_secret_provider() secret_provider = get_secret_provider()
@ -51,29 +55,44 @@ async def get_bearer_token(shared_secret: str, client_key: str):
@router.get("/") @router.get("/")
async def handle_manifest(request: Request, response: Response): async def handle_manifest(request: Request, response: Response):
manifest = open("atlassian-connect.json", "rt").read() manifest = open("atlassian-connect.json", "rt").read()
try:
manifest = manifest.replace("app_key", get_settings().bitbucket.app_key)
except:
logging.error("Failed to replace api_key in Bitbucket manifest, trying to continue")
manifest_obj = json.loads(manifest) manifest_obj = json.loads(manifest)
return JSONResponse(manifest_obj) return JSONResponse(manifest_obj)
@router.post("/webhook") @router.post("/webhook")
async def handle_github_webhooks(request: Request, response: Response): async def handle_github_webhooks(background_tasks: BackgroundTasks, request: Request):
try:
print(request.headers) print(request.headers)
jwt_header = request.headers.get("authorization", None)
if jwt_header:
input_jwt = jwt_header.split(" ")[1]
data = await request.json() data = await request.json()
print(data) print(data)
async def inner():
try:
owner = data["data"]["repository"]["owner"]["username"] owner = data["data"]["repository"]["owner"]["username"]
secrets = json.loads(secret_provider.get_secret(owner)) secrets = json.loads(secret_provider.get_secret(owner))
shared_secret = secrets["shared_secret"] shared_secret = secrets["shared_secret"]
client_key = secrets["client_key"] client_key = secrets["client_key"]
jwt.decode(input_jwt, shared_secret, audience=client_key, algorithms=["HS256"])
bearer_token = await get_bearer_token(shared_secret, client_key) bearer_token = await get_bearer_token(shared_secret, client_key)
context['bitbucket_bearer_token'] = bearer_token context['bitbucket_bearer_token'] = bearer_token
context["settings"] = copy.deepcopy(global_settings)
event = data["event"] event = data["event"]
agent = PRAgent() agent = PRAgent()
if event == "pullrequest:created": if event == "pullrequest:created":
pr_url = data["data"]["pullrequest"]["links"]["html"]["href"] pr_url = data["data"]["pullrequest"]["links"]["html"]["href"]
await agent.handle_request(pr_url, "review") await agent.handle_request(pr_url, "review")
elif event == "pullrequest:comment_created":
pr_url = data["data"]["pullrequest"]["links"]["html"]["href"]
comment_body = data["data"]["comment"]["content"]["raw"]
await agent.handle_request(pr_url, comment_body)
except Exception as e: except Exception as e:
logging.error(f"Failed to handle webhook: {e}") logging.error(f"Failed to handle webhook: {e}")
return JSONResponse({"error": "Unable to handle webhook"}, status_code=500) background_tasks.add_task(inner)
return "OK"
@router.get("/webhook") @router.get("/webhook")
async def handle_github_webhooks(request: Request, response: Response): async def handle_github_webhooks(request: Request, response: Response):
@ -106,6 +125,7 @@ async def handle_uninstalled_webhooks(request: Request, response: Response):
def start(): def start():
get_settings().set("CONFIG.PUBLISH_OUTPUT_PROGRESS", False) get_settings().set("CONFIG.PUBLISH_OUTPUT_PROGRESS", False)
get_settings().set("CONFIG.GIT_PROVIDER", "bitbucket") get_settings().set("CONFIG.GIT_PROVIDER", "bitbucket")
get_settings().set("PR_DESCRIPTION.PUBLISH_DESCRIPTION_AS_COMMENT", True)
middleware = [Middleware(RawContextMiddleware)] middleware = [Middleware(RawContextMiddleware)]
app = FastAPI(middleware=middleware) app = FastAPI(middleware=middleware)
app.include_router(router) app.include_router(router)

View File

@ -153,12 +153,6 @@ class PRDescription:
# Initialization # Initialization
pr_types = [] pr_types = []
# Iterate over the dictionary items and append the key and value to 'markdown_text' in a markdown format
markdown_text = ""
for key, value in data.items():
markdown_text += f"## {key}\n\n"
markdown_text += f"{value}\n\n"
# If the 'PR Type' key is present in the dictionary, split its value by comma and assign it to 'pr_types' # If the 'PR Type' key is present in the dictionary, split its value by comma and assign it to 'pr_types'
if 'PR Type' in data: if 'PR Type' in data:
if type(data['PR Type']) == list: if type(data['PR Type']) == list:
@ -194,6 +188,8 @@ class PRDescription:
if idx < len(data) - 1: if idx < len(data) - 1:
pr_body += "\n___\n" pr_body += "\n___\n"
markdown_text = f"## Title\n\n{title}\n\n___\n{pr_body}"
if get_settings().config.verbosity_level >= 2: if get_settings().config.verbosity_level >= 2:
logging.info(f"title:\n{title}\n{pr_body}") logging.info(f"title:\n{title}\n{pr_body}")